Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ reader to an apparently empty page.
- `a` — accept the current pending thread
- `d` — discard the current pending thread
- `Enter` — submit reply; `Shift+Enter` — newline
- 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.

### How thread data flows
- Thread data is **server-rendered** as hidden `[data-anchor-text]` elements in `#plan-threads` (via `_thread_popover.html.erb`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# This migration comes from co_plan (originally 20260820000000)
class BackfillVoiceHotkeyPreference < ActiveRecord::Migration[8.1]
# Push-to-talk used to be Shift, for everyone, with no way to change it.
# It is now a setting, and the default for anyone new is Ctrl+Space —
# a deliberate chord that can open the microphone the instant it's
# pressed, where a bare Shift has to wait out a hold delay to tell
# talking from typing a capital.
#
# Nobody's hands should have to relearn that on a deploy, so everyone
# who already exists is written down as a Shift user explicitly. From
# here on, "no preference recorded" means "arrived after the setting
# existed" and reads as Ctrl+Space.
#
# Idempotent: only users with no voice_hotkey recorded are touched.
def up
CoPlan::User.find_each do |user|
metadata = user.metadata || {}
next if metadata.key?("voice_hotkey")

user.update_column(:metadata, metadata.merge("voice_hotkey" => "shift")) # rubocop:disable Rails/SkipsModelValidations
end
end

def down
CoPlan::User.find_each do |user|
metadata = user.metadata
next unless metadata.is_a?(Hash) && metadata["voice_hotkey"] == "shift"

user.update_column(:metadata, metadata.except("voice_hotkey")) # rubocop:disable Rails/SkipsModelValidations
end
end
end
2 changes: 1 addition & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 100 additions & 9 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ img, svg {
}

.voice-control .voice-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
Expand All @@ -852,6 +853,40 @@ img, svg {
transform: scale(1.06);
}

/* Hovering names the key. Push-to-talk is chosen in Settings and nothing
else on the page says what it ended up as — a mic you have to hold the
right key for is only discoverable if the mic tells you which one.
Written by the controller, which knows the platform: the same
preference reads as ⌥ Option on a Mac and Alt everywhere else. */
.voice-control .voice-btn[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
right: 0;
background: var(--color-text);
color: var(--color-surface);
padding: 4px 8px;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
}

.voice-control .voice-btn[data-tooltip]:hover::after {
opacity: 1;
}

/* Mid-take the button is a stop button, and the status text is already
saying what's happening — an instruction for starting would contradict
both. */
.voice-control .voice-btn--listening[data-tooltip]::after {
content: none;
}

/* Recording reads as recording: the button fills red, and a ring grows
with how loud you are. The fill says "the mic is open"; only the ring
says "it can hear you", which is the question you actually have while
Expand Down Expand Up @@ -2468,6 +2503,10 @@ img.avatar {
line-height: 1.5;
}

.settings-row__hint--error {
color: var(--color-danger);
}

.settings-row__action {
flex: 0 0 auto;
display: flex;
Expand Down Expand Up @@ -2531,29 +2570,35 @@ img.avatar {
margin-top: var(--space-xs);
}

/* Theme switcher (segmented control) */
.theme-switcher {
/* Segmented control (theme switcher, push-to-talk key) */
.segmented {
display: inline-flex;
/* The control is the row's point; the explanation next to it is what
gives way when the window is narrow. */
flex: 0 0 auto;
Comment thread
HamptonMakes marked this conversation as resolved.
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}

.theme-switcher__option {
.segmented__option {
cursor: pointer;
margin: 0;
}

.theme-switcher__option input[type="radio"] {
.segmented__option input[type="radio"] {
position: absolute;
opacity: 0;
pointer-events: none;
}

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

.theme-switcher__option:last-child .theme-switcher__btn {
.segmented__option:last-child .segmented__btn {
border-right: none;
}

.theme-switcher__btn svg {
.segmented__btn svg {
flex-shrink: 0;
}

.theme-switcher__option:hover .theme-switcher__btn {
.segmented__option:hover .segmented__btn {
background: var(--color-bg-muted);
color: var(--color-text);
}

.theme-switcher__option:has(input:checked) .theme-switcher__btn {
.segmented__option:has(input:checked) .segmented__btn {
background: var(--color-primary-light);
color: var(--color-primary);
font-weight: 600;
}

/* On a phone there is no width to negotiate over, so the label goes above
the control and the strip takes the whole row. Side by side, the four
push-to-talk options either run off the screen or crush the explanation
into one word per line.
Segments share the leftover width rather than splitting it evenly:
"Ctrl+Space" has no space to break at, so an equal quarter of a 320px
screen clips it, while sized-to-content segments fit with room over. */
@media (max-width: 640px) {
.settings-row {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}

.settings-row__action {
align-items: stretch;
}

.segmented {
display: flex;
width: 100%;
/* Below about 350px even sized-to-content segments run out of room,
so the strip takes a second line rather than hiding the last
option off the edge of the screen. */
flex-wrap: wrap;
}

.segmented__option {
flex: 1 1 auto;
}

/* A wrapped row needs a rule above it, which the horizontal strip
never did. */
.segmented__btn {
border-top: 1px solid var(--color-border);
margin-top: -1px;
}

.segmented__btn {
justify-content: center;
width: 100%;
padding: var(--space-sm) var(--space-xs);
font-size: var(--text-xs);
}
}

/* Thread popover data containers (hidden, provide data for JS) */
.thread-popover-data {
display: contents;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ def update_theme
end
head :ok
end

def update_voice_hotkey
hotkey = params[:voice_hotkey]
if CoPlan::User::VOICE_HOTKEYS.include?(hotkey)
current_user.voice_hotkey = hotkey
current_user.save!
end
head :ok
end
end
end
end
10 changes: 10 additions & 0 deletions engine/app/helpers/coplan/plans_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ def plan_type_icon(plan, size: :md)
aria: { label: "#{plan_type.name} document" })
end

# What the mic button says about itself. The push-to-talk key is a
# setting, so the button is the only place on the page that can tell
# you which key yours ended up being — and "hold something to talk"
# is worse than saying nothing.
def voice_button_description(hotkey)
return "Comment by voice" if hotkey == "off"

"Comment by voice — or hold #{User::VOICE_HOTKEY_LABELS[hotkey]} to talk"
end

def plan_content_preview(plan, limit: 200)
stub = plan.current_version_stub
return nil if stub.nil?
Expand Down
Loading
Loading