Skip to content

Commit 4f40912

Browse files
HamptonMakesclaude
andauthored
Let people choose their push-to-talk key (#188)
* Let people choose their push-to-talk key Push-to-talk was Shift, for everyone, forever. Shift is a key people are already pressing all day, which is why the gesture needed a 350ms hold delay and a selection check to tell talking from typing a capital — and why it still couldn't work at all for anyone whose browser or OS already spends the key. It's now a setting: Ctrl+Space (the new default), Shift, Option/Alt, or off. Presets rather than a free capture, because each one behaves differently on purpose. A chord is nobody's accident, so it records from the press — no delay to sit out and nothing to guess. A bare modifier keeps all the old hedging, because the ambiguity is real. Everyone who was already here is written down as a Shift user by a backfill, so no hands have to relearn anything on deploy; "no preference recorded" now means "arrived after the setting existed". The mic names its own key on hover and to a screen reader — with the shortcut configurable, the button is the only thing on the page that can say which one yours is. The server renders it; the controller rewrites it knowing whether that key says Option or Alt on this machine. Also folds the theme switcher's CSS into a shared .segmented control rather than growing a second copy of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Say when a hotkey save fails, and fit the picker on a phone Two things from review. The PATCH was fire-and-forget: a refused or dropped save left the page lit up on a key the server never took, discovered only on the next visit when push-to-talk silently wasn't what the settings said. The click stays the feedback — no spinner narrating a round trip that takes milliseconds — but a failure now puts the previous choice back and says so. And the picker didn't fit a phone. Its no-shrink rule kept the strip at full width, which crushed the explanation into one word per line and ran the last option off the screen. Below 640px the row stacks and the strip takes the width; segments size to content rather than splitting it evenly, because "Ctrl+Space" has nowhere to break and an equal quarter clips it; below about 350px the strip wraps to a second line rather than hiding "Off" off the edge. Fixes the Theme row at those widths too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8b2a8c8 commit 4f40912

18 files changed

Lines changed: 635 additions & 40 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ reader to an apparently empty page.
167167
- `a` — accept the current pending thread
168168
- `d` — discard the current pending thread
169169
- `Enter` — submit reply; `Shift+Enter` — newline
170+
- Push-to-talk (hold to dictate a comment) is a per-user setting — `Ctrl+Space` by default, or Shift / Option / off (`CoPlan::User::VOICE_HOTKEYS`, `voice_controller.js`). A bare modifier has to be held past a delay to tell talking from typing; a chord records from the press.
170171

171172
### How thread data flows
172173
- Thread data is **server-rendered** as hidden `[data-anchor-text]` elements in `#plan-threads` (via `_thread_popover.html.erb`)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# This migration comes from co_plan (originally 20260820000000)
2+
class BackfillVoiceHotkeyPreference < ActiveRecord::Migration[8.1]
3+
# Push-to-talk used to be Shift, for everyone, with no way to change it.
4+
# It is now a setting, and the default for anyone new is Ctrl+Space —
5+
# a deliberate chord that can open the microphone the instant it's
6+
# pressed, where a bare Shift has to wait out a hold delay to tell
7+
# talking from typing a capital.
8+
#
9+
# Nobody's hands should have to relearn that on a deploy, so everyone
10+
# who already exists is written down as a Shift user explicitly. From
11+
# here on, "no preference recorded" means "arrived after the setting
12+
# existed" and reads as Ctrl+Space.
13+
#
14+
# Idempotent: only users with no voice_hotkey recorded are touched.
15+
def up
16+
CoPlan::User.find_each do |user|
17+
metadata = user.metadata || {}
18+
next if metadata.key?("voice_hotkey")
19+
20+
user.update_column(:metadata, metadata.merge("voice_hotkey" => "shift")) # rubocop:disable Rails/SkipsModelValidations
21+
end
22+
end
23+
24+
def down
25+
CoPlan::User.find_each do |user|
26+
metadata = user.metadata
27+
next unless metadata.is_a?(Hash) && metadata["voice_hotkey"] == "shift"
28+
29+
user.update_column(:metadata, metadata.except("voice_hotkey")) # rubocop:disable Rails/SkipsModelValidations
30+
end
31+
end
32+
end

db/schema.rb

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

engine/app/assets/stylesheets/coplan/application.css

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,7 @@ img, svg {
832832
}
833833

834834
.voice-control .voice-btn {
835+
position: relative;
835836
display: inline-flex;
836837
align-items: center;
837838
justify-content: center;
@@ -852,6 +853,40 @@ img, svg {
852853
transform: scale(1.06);
853854
}
854855

856+
/* Hovering names the key. Push-to-talk is chosen in Settings and nothing
857+
else on the page says what it ended up as — a mic you have to hold the
858+
right key for is only discoverable if the mic tells you which one.
859+
Written by the controller, which knows the platform: the same
860+
preference reads as ⌥ Option on a Mac and Alt everywhere else. */
861+
.voice-control .voice-btn[data-tooltip]::after {
862+
content: attr(data-tooltip);
863+
position: absolute;
864+
bottom: calc(100% + 8px);
865+
right: 0;
866+
background: var(--color-text);
867+
color: var(--color-surface);
868+
padding: 4px 8px;
869+
border-radius: var(--radius);
870+
font-size: 0.75rem;
871+
font-weight: 500;
872+
white-space: nowrap;
873+
pointer-events: none;
874+
opacity: 0;
875+
transition: opacity 0.15s;
876+
z-index: 10;
877+
}
878+
879+
.voice-control .voice-btn[data-tooltip]:hover::after {
880+
opacity: 1;
881+
}
882+
883+
/* Mid-take the button is a stop button, and the status text is already
884+
saying what's happening — an instruction for starting would contradict
885+
both. */
886+
.voice-control .voice-btn--listening[data-tooltip]::after {
887+
content: none;
888+
}
889+
855890
/* Recording reads as recording: the button fills red, and a ring grows
856891
with how loud you are. The fill says "the mic is open"; only the ring
857892
says "it can hear you", which is the question you actually have while
@@ -2468,6 +2503,10 @@ img.avatar {
24682503
line-height: 1.5;
24692504
}
24702505

2506+
.settings-row__hint--error {
2507+
color: var(--color-danger);
2508+
}
2509+
24712510
.settings-row__action {
24722511
flex: 0 0 auto;
24732512
display: flex;
@@ -2531,29 +2570,35 @@ img.avatar {
25312570
margin-top: var(--space-xs);
25322571
}
25332572

2534-
/* Theme switcher (segmented control) */
2535-
.theme-switcher {
2573+
/* Segmented control (theme switcher, push-to-talk key) */
2574+
.segmented {
25362575
display: inline-flex;
2576+
/* The control is the row's point; the explanation next to it is what
2577+
gives way when the window is narrow. */
2578+
flex: 0 0 auto;
25372579
border: 1px solid var(--color-border);
25382580
border-radius: var(--radius);
25392581
overflow: hidden;
25402582
}
25412583

2542-
.theme-switcher__option {
2584+
.segmented__option {
25432585
cursor: pointer;
25442586
margin: 0;
25452587
}
25462588

2547-
.theme-switcher__option input[type="radio"] {
2589+
.segmented__option input[type="radio"] {
25482590
position: absolute;
25492591
opacity: 0;
25502592
pointer-events: none;
25512593
}
25522594

2553-
.theme-switcher__btn {
2595+
.segmented__btn {
25542596
display: inline-flex;
25552597
align-items: center;
25562598
gap: var(--space-xs);
2599+
/* Segments are labels for one choice each — a wrapped one reads as two
2600+
options and leaves the control ragged. */
2601+
white-space: nowrap;
25572602
padding: var(--space-sm) var(--space-md);
25582603
font-size: var(--text-sm);
25592604
font-weight: 500;
@@ -2562,25 +2607,71 @@ img.avatar {
25622607
border-right: 1px solid var(--color-border);
25632608
}
25642609

2565-
.theme-switcher__option:last-child .theme-switcher__btn {
2610+
.segmented__option:last-child .segmented__btn {
25662611
border-right: none;
25672612
}
25682613

2569-
.theme-switcher__btn svg {
2614+
.segmented__btn svg {
25702615
flex-shrink: 0;
25712616
}
25722617

2573-
.theme-switcher__option:hover .theme-switcher__btn {
2618+
.segmented__option:hover .segmented__btn {
25742619
background: var(--color-bg-muted);
25752620
color: var(--color-text);
25762621
}
25772622

2578-
.theme-switcher__option:has(input:checked) .theme-switcher__btn {
2623+
.segmented__option:has(input:checked) .segmented__btn {
25792624
background: var(--color-primary-light);
25802625
color: var(--color-primary);
25812626
font-weight: 600;
25822627
}
25832628

2629+
/* On a phone there is no width to negotiate over, so the label goes above
2630+
the control and the strip takes the whole row. Side by side, the four
2631+
push-to-talk options either run off the screen or crush the explanation
2632+
into one word per line.
2633+
Segments share the leftover width rather than splitting it evenly:
2634+
"Ctrl+Space" has no space to break at, so an equal quarter of a 320px
2635+
screen clips it, while sized-to-content segments fit with room over. */
2636+
@media (max-width: 640px) {
2637+
.settings-row {
2638+
flex-direction: column;
2639+
align-items: stretch;
2640+
gap: var(--space-sm);
2641+
}
2642+
2643+
.settings-row__action {
2644+
align-items: stretch;
2645+
}
2646+
2647+
.segmented {
2648+
display: flex;
2649+
width: 100%;
2650+
/* Below about 350px even sized-to-content segments run out of room,
2651+
so the strip takes a second line rather than hiding the last
2652+
option off the edge of the screen. */
2653+
flex-wrap: wrap;
2654+
}
2655+
2656+
.segmented__option {
2657+
flex: 1 1 auto;
2658+
}
2659+
2660+
/* A wrapped row needs a rule above it, which the horizontal strip
2661+
never did. */
2662+
.segmented__btn {
2663+
border-top: 1px solid var(--color-border);
2664+
margin-top: -1px;
2665+
}
2666+
2667+
.segmented__btn {
2668+
justify-content: center;
2669+
width: 100%;
2670+
padding: var(--space-sm) var(--space-xs);
2671+
font-size: var(--text-xs);
2672+
}
2673+
}
2674+
25842675
/* Thread popover data containers (hidden, provide data for JS) */
25852676
.thread-popover-data {
25862677
display: contents;

engine/app/controllers/coplan/settings/settings_controller.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ def update_theme
1414
end
1515
head :ok
1616
end
17+
18+
def update_voice_hotkey
19+
hotkey = params[:voice_hotkey]
20+
if CoPlan::User::VOICE_HOTKEYS.include?(hotkey)
21+
current_user.voice_hotkey = hotkey
22+
current_user.save!
23+
end
24+
head :ok
25+
end
1726
end
1827
end
1928
end

engine/app/helpers/coplan/plans_helper.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ def plan_type_icon(plan, size: :md)
8787
aria: { label: "#{plan_type.name} document" })
8888
end
8989

90+
# What the mic button says about itself. The push-to-talk key is a
91+
# setting, so the button is the only place on the page that can tell
92+
# you which key yours ended up being — and "hold something to talk"
93+
# is worse than saying nothing.
94+
def voice_button_description(hotkey)
95+
return "Comment by voice" if hotkey == "off"
96+
97+
"Comment by voice — or hold #{User::VOICE_HOTKEY_LABELS[hotkey]} to talk"
98+
end
99+
90100
def plan_content_preview(plan, limit: 200)
91101
stub = plan.current_version_stub
92102
return nil if stub.nil?

0 commit comments

Comments
 (0)