From 81769a91bb4a2b808f06df6003e3645b70870d4a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 11:58:22 +1000 Subject: [PATCH 1/7] feat(acp): add vertical title/subtitle layout to config picker trigger The ACP config picker trigger now supports a configurable layout: the existing horizontal style renders model and effort inline in the same style separated by a dot, while the new vertical style renders the model as a title with the effort as a smaller subtitle below it. New-session surfaces keep the horizontal default; the session chat pane opts into vertical, with its trigger switched from a fixed h-9 to min-h-9 so the two-line label can grow the button. The trailing spinner/chevron swap is also wrapped in a fixed 12px slot with the icons absolutely positioned, so toggling between loading and idle no longer changes how much horizontal space the trailing icon takes (previously the spinner could constrain the label while the initial discovery load was in flight). Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../features/agents/AcpConfigPicker.svelte | 3 + .../agents/AcpConfigPickerShell.svelte | 65 ++++++++++++++++--- .../agents/AcpFixedConfigPicker.svelte | 3 + .../features/sessions/SessionChatPane.svelte | 4 +- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/staged/src/lib/features/agents/AcpConfigPicker.svelte b/apps/staged/src/lib/features/agents/AcpConfigPicker.svelte index 73e4539f..2bc6218c 100644 --- a/apps/staged/src/lib/features/agents/AcpConfigPicker.svelte +++ b/apps/staged/src/lib/features/agents/AcpConfigPicker.svelte @@ -30,6 +30,7 @@ remote?: boolean; dropUp?: boolean; triggerClass?: string; + layout?: 'horizontal' | 'vertical'; workingDir?: string | null; onSelectionChange?: (selection: AcpConfigPickerSelection) => void; } @@ -39,6 +40,7 @@ remote = false, dropUp = false, triggerClass, + layout = 'horizontal', workingDir = null, onSelectionChange, }: Props = $props(); @@ -298,6 +300,7 @@ {disabled} {dropUp} {triggerClass} + {layout} {canOpen} >
diff --git a/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte b/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte index c94f8cf7..3f7b2957 100644 --- a/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte +++ b/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte @@ -25,6 +25,12 @@ loading?: boolean; canOpen?: boolean; hasColumns?: boolean; + /** + * horizontal: all parts inline in the same style, separated by "·". + * vertical: first part is the title; later parts stack below it in + * smaller text. + */ + layout?: 'horizontal' | 'vertical'; children?: Snippet; footer?: Snippet; } @@ -40,6 +46,7 @@ loading = false, canOpen = true, hasColumns = true, + layout = 'horizontal', children, footer, }: Props = $props(); @@ -80,14 +87,15 @@ {#snippet labelContent()} {#each renderedTriggerParts as part, index (part.id)} - {#if index > 0} + {#if layout === 'horizontal' && index > 0} {/if} - + 0}> {#key part.label} @@ -122,11 +130,13 @@ > {@render labelContent()} - {#if loading} - - {:else} - - {/if} + void; onEffortChange?: (value: string) => void; } @@ -34,6 +35,7 @@ disabled = false, dropUp = false, triggerClass, + layout = 'horizontal', onModelChange, onEffortChange, }: Props = $props(); @@ -122,6 +124,7 @@ {disabled} {dropUp} {triggerClass} + {layout} hasColumns={hasPickerColumns} > {#if modelSelector} diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte index 0f5b2fdb..a0ff5182 100644 --- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte @@ -215,8 +215,9 @@ let followupModelStateKey = $state(null); let followupEffortStateKey = $state(null); let followupDiscoveryRun = 0; + // min-h instead of h so the vertical model/effort label can grow the button. const footerControlClass = - 'h-9 gap-1.5 rounded-md border border-[var(--border-muted)] bg-[var(--bg-primary)] px-4 py-2 text-sm font-medium text-muted-foreground shadow-none transition-colors hover:bg-[var(--bg-hover)] hover:text-foreground max-[640px]:h-11 max-[640px]:justify-center'; + 'min-h-9 gap-1.5 rounded-md border border-[var(--border-muted)] bg-[var(--bg-primary)] px-4 py-1 text-sm font-medium text-muted-foreground shadow-none transition-colors hover:bg-[var(--bg-hover)] hover:text-foreground max-[640px]:min-h-11 max-[640px]:justify-center'; let isLive = $derived(session?.status === 'running'); let hasQueuedMessages = $derived(queuedMessages.length > 0); @@ -2164,6 +2165,7 @@ disabled={isLive || sending} dropUp triggerClass={footerControlClass} + layout="vertical" onModelChange={handleFollowupModelChange} onEffortChange={handleFollowupEffortChange} /> From d9b418681226721a2f1a08ac3ff0bd88da0f8491 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 12:19:49 +1000 Subject: [PATCH 2/7] fix(acp): measure trigger label sizer with transform-independent sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker trigger's label width comes from a ResizeObserver watching a hidden sizer, but the callback read the width via getBoundingClientRect, which includes ancestor transforms. The trigger mounts inside dialogs (session/note modals, new-session modal) whose open animation scales the content from 0.95, so any measurement taken during that window came in ~5% short. Because the sizer's local size never changes afterwards, the observer never fired again and the label stayed truncated — visible as the provider label being cut while the discovery spinner showed, and as permanently shortened model/effort text in chat when cached discovery resolved within the animation window. Read the width from the observer entry (borderBoxSize, falling back to contentRect) instead; entry sizes are reported in local CSS pixels and are unaffected by ancestor transforms. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../lib/features/agents/AcpConfigPickerShell.svelte | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte b/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte index 3f7b2957..d84e45a6 100644 --- a/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte +++ b/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte @@ -67,9 +67,17 @@ labelWidth = null; return; } - const observer = new ResizeObserver(() => { + const observer = new ResizeObserver((entries) => { + // Measure from the observer entry, not getBoundingClientRect: the + // trigger mounts inside dialogs whose open animation scales the content + // (zoom-in-95), and a rect captured mid-animation is ~5% short. The + // sizer's local size never changes afterwards, so the observer would + // not fire again and the label would stay truncated. Entry sizes are in + // local CSS pixels, unaffected by ancestor transforms. + const entry = entries[entries.length - 1]; + const width = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; // Round up so sub-pixel clipping never triggers part ellipsis. - labelWidth = Math.ceil(sizer.getBoundingClientRect().width); + labelWidth = Math.ceil(width); }); observer.observe(sizer); return () => observer.disconnect(); From 8a65cedb6d1c46c5b17da7425594e98fadc697fe Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 13:19:13 +1000 Subject: [PATCH 3/7] feat(chat): share one bottom composer between note chat pane and chat dialog The note dialog's chat pane and the session chat dialog both render SessionChatPane, but the bottom input UI diverged: compact panes rewrote the single-row layout into two rows via CSS wrap/order overrides, and the attach affordance changed shape depending on whether images were attached. Extract the bottom input into a shared ChatComposer component housing the text input, send/stop button, attach button, and attached-image strip, so both surfaces render the identical composer. The layout is now always the note-pane style: the text input gets its own full-width row with the controls beneath it. The attach button is styled like the agent config picker trigger (same border, background, and height chrome, built from the same base class that the composer now exports as composerControlClass) and stays visible once images are attached. The attached-image strip loses its own dashed add button and sits inside the composer, sharing its background instead of drawing a separately-bordered band above the input. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../lib/features/sessions/ChatComposer.svelte | 287 +++++++++++++++++ .../features/sessions/SessionChatPane.svelte | 290 +++--------------- 2 files changed, 332 insertions(+), 245 deletions(-) create mode 100644 apps/staged/src/lib/features/sessions/ChatComposer.svelte diff --git a/apps/staged/src/lib/features/sessions/ChatComposer.svelte b/apps/staged/src/lib/features/sessions/ChatComposer.svelte new file mode 100644 index 00000000..c05e4ef3 --- /dev/null +++ b/apps/staged/src/lib/features/sessions/ChatComposer.svelte @@ -0,0 +1,287 @@ + + + + + +
+ {#if canAttachImages && attachedImageIds.length > 0} +
+ {#each attachedImageIds as imageId (imageId)} +
+ {#if imagePreviews.get(imageId)} + attached + {:else} +
+ {/if} + {#if !isLive} + + {/if} +
+ {/each} +
+ {/if} + +
+ {@render configPicker?.()} + {#if canAttachImages} + + + + + {/if} + {#if isLive} + + + + {:else} + + + + {/if} +
+
+ + diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte index a0ff5182..167458c8 100644 --- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte @@ -43,9 +43,7 @@ import Zap from '@lucide/svelte/icons/zap'; import GitBranch from '@lucide/svelte/icons/git-branch'; import FileText from '@lucide/svelte/icons/file-text'; - import Paperclip from '@lucide/svelte/icons/paperclip'; import ImagePlus from '@lucide/svelte/icons/image-plus'; - import Plus from '@lucide/svelte/icons/plus'; import Spinner from '../../shared/Spinner.svelte'; import { isResumableReason } from '../../types'; import type { @@ -83,7 +81,7 @@ import AcpFixedConfigPicker from '../agents/AcpFixedConfigPicker.svelte'; import { agentState } from '../agents/agent.svelte'; import { buildAcpConfigSelection } from '../agents/acpConfigSelection'; - import HashtagInput from './HashtagInput.svelte'; + import ChatComposer, { composerControlClass } from './ChatComposer.svelte'; import { buildBranchHashtagItems, findHashtagItemForReference, @@ -215,9 +213,6 @@ let followupModelStateKey = $state(null); let followupEffortStateKey = $state(null); let followupDiscoveryRun = 0; - // min-h instead of h so the vertical model/effort label can grow the button. - const footerControlClass = - 'min-h-9 gap-1.5 rounded-md border border-[var(--border-muted)] bg-[var(--bg-primary)] px-4 py-1 text-sm font-medium text-muted-foreground shadow-none transition-colors hover:bg-[var(--bg-hover)] hover:text-foreground max-[640px]:min-h-11 max-[640px]:justify-center'; let isLive = $derived(session?.status === 'running'); let hasQueuedMessages = $derived(queuedMessages.length > 0); @@ -355,7 +350,6 @@ let canAttachImages = $derived(!!projectId); let replyImageIds = $state([]); let imagePreviews = $state>(new Map()); - let imageFileInput = $state(); // Drag-and-drop state let dragOver = $state(false); @@ -413,17 +407,10 @@ } }); - function openImagePicker() { - imageFileInput?.click(); - } - - async function handleImageFileSelect(e: Event) { - const input = e.target as HTMLInputElement; - if (!input.files) return; - for (const file of Array.from(input.files)) { + async function handleAttachFiles(files: File[]) { + for (const file of files) { await addImageFile(file); } - input.value = ''; } async function addImageFile(file: File) { @@ -2031,7 +2018,7 @@
{#if isPipelinePrelude} -
+
- {/if} -
- {/each} - {#if !isLive} - - {/if} -
- {/if} -
- - {#if canAttachImages} - + {#snippet configPicker()} + - {#if replyImageIds.length === 0} - - - - {/if} - {/if} - - {#if isLive} - - - - {:else} - - - - {/if} -
+ {/snippet} + {/if}
@@ -2284,28 +2184,6 @@ max-width: 92%; } - /* Compact panes are too narrow to fit the config picker, attach, input, and - send controls in one row: give the text input its own full-width row with - the controls beneath it. */ - .session-chat-pane.compact .input-area { - padding: 8px 10px; - flex-wrap: wrap; - } - - .session-chat-pane.compact .input-area :global(.hashtag-input-wrapper) { - order: -1; - flex-basis: 100%; - } - - .session-chat-pane.compact .input-area .composer-send { - margin-left: auto; - } - - .session-chat-pane.compact .queue-popover, - .session-chat-pane.compact .slash-command-popover { - margin: 0 10px; - } - /* ----- Content area (scrollable) --------------------------------------- */ .modal-content { @@ -2525,15 +2403,12 @@ .human-bubble:focus-within :global(.message-copy-action), .assistant-content:focus-within :global(.message-copy-action), - .reply-image-thumb:focus-within :global(.reply-image-remove-action), - :global(.message-copy-action:focus-visible), - :global(.reply-image-remove-action:focus-visible) { + :global(.message-copy-action:focus-visible) { opacity: 1; } @media (hover: none), (pointer: coarse) { - :global(.message-copy-action), - :global(.reply-image-remove-action) { + :global(.message-copy-action) { opacity: 1; } } @@ -2969,92 +2844,17 @@ word-break: break-word; } - /* ----- Reply image previews -------------------------------------------- */ - - .file-input-hidden { - display: none; - } - - .reply-images { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - padding: 8px 14px 0; - border-top: 1px solid var(--border-subtle); - } - - .reply-images + .input-area { - border-top: none; - } - - .reply-image-thumb { - position: relative; - width: 48px; - height: 48px; - border-radius: 6px; - overflow: hidden; - border: 1px solid var(--border-muted); - background: var(--bg-hover); - flex-shrink: 0; - } - - .reply-image-thumb img { - width: 100%; - height: 100%; - object-fit: cover; - } - - .reply-image-placeholder { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - color: var(--text-faint); - } - - /* ----- Input area ------------------------------------------------------ */ + /* ----- Pipeline stop area ---------------------------------------------- */ - .input-area { + .pipeline-stop-area { display: flex; - align-items: flex-end; - gap: 8px; + justify-content: flex-end; padding: 10px 14px; border-top: 1px solid var(--border-subtle); background: var(--bg-chrome); flex-shrink: 0; } - .pipeline-stop-area { - justify-content: flex-end; - } - - .input-area :global(.message-input) { - flex: 1; - padding: 7px 12px; - background: var(--bg-primary); - border: 1px solid var(--border-muted); - border-radius: 10px; - color: var(--text-primary); - font-size: var(--size-md); - font-family: inherit; - line-height: 1.5; - resize: none; - overflow-y: hidden; - min-height: 36px; - max-height: 120px; - } - - .input-area :global(.message-input):focus { - outline: none; - border-color: var(--border-emphasis); - } - - .input-area :global(.hashtag-input-wrapper) { - flex: 1; - } - /* ======================================================================= */ /* Markdown content (assistant messages) */ /* ======================================================================= */ From 642ade6720f68b6c18aab14de8f00c1eebd3bdf5 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 13:35:48 +1000 Subject: [PATCH 4/7] feat(chat): style composer send button like the new-session submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared chat composer's send button was a small outline icon button, unlike the accent-filled submit in the new session dialog. Restyle it to match NewSessionModal's submit: accent background with hover shade, the Send icon at 14px next to a "Send" label (Spinner + "Sending…" while in flight), text-sm font-semibold, px-4 py-2. The mobile height bump uses the composer's existing 640px breakpoint so it grows together with the adjacent config picker and attach controls. The stop button shown while a session is running is unchanged. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../src/lib/features/sessions/ChatComposer.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/staged/src/lib/features/sessions/ChatComposer.svelte b/apps/staged/src/lib/features/sessions/ChatComposer.svelte index c05e4ef3..a2e79ea2 100644 --- a/apps/staged/src/lib/features/sessions/ChatComposer.svelte +++ b/apps/staged/src/lib/features/sessions/ChatComposer.svelte @@ -172,17 +172,17 @@ {:else} From a368ff162fe9ed34cde08894fb4378e7734982ad Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 9 Jul 2026 13:59:38 +1000 Subject: [PATCH 5/7] feat(ui): drop paper plane icon and unify accent style on send buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start/send/queue buttons now share one look: a label-only accent-filled button, with a spinner while the action is in flight. The blue styling from the new-session submit becomes an `accent` Button variant (ui-accent background with hover shade, semibold, primary-foreground text) so call sites no longer copy the class string around. Applied to the new-session dialog submit (Start/Queue/New), the chat composer send, the diff pane's Start/Queue commit launcher (previously outline), and the queued-message send in the chat queue popover — which was an icon-only ghost button and becomes a mini accent "Send" text button. The paper plane icon is removed everywhere and its imports dropped. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src/lib/components/ui/button/button.svelte | 2 ++ .../lib/features/diff/DiffCommitSessionLauncher.svelte | 4 +--- .../staged/src/lib/features/sessions/ChatComposer.svelte | 6 ++---- .../src/lib/features/sessions/NewSessionModal.svelte | 6 ++---- .../src/lib/features/sessions/SessionChatPane.svelte | 9 ++++----- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/staged/src/lib/components/ui/button/button.svelte b/apps/staged/src/lib/components/ui/button/button.svelte index ad3845df..51098b4a 100644 --- a/apps/staged/src/lib/components/ui/button/button.svelte +++ b/apps/staged/src/lib/components/ui/button/button.svelte @@ -8,6 +8,8 @@ variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/80', + accent: + 'appearance-none bg-[var(--ui-accent)] font-semibold text-primary-foreground shadow-none hover:bg-[var(--ui-accent-hover)] disabled:bg-[var(--ui-accent)]', outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground', secondary: diff --git a/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte b/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte index c3dc0876..ac5770ae 100644 --- a/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte +++ b/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte @@ -1,6 +1,5 @@