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
59 changes: 19 additions & 40 deletions apps/staged/src/lib/features/sessions/ChatComposer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
ChatComposer.svelte — Shared bottom chat input for session chat surfaces.

Houses the attached-image strip, the text input, and the control row below
it (agent config picker slot, attach button, send/stop button). Rendered by
it (agent config picker slot, attach button, send/queue button). Rendered by
SessionChatPane, which backs both the session dialog and the note dialog's
chat pane, so the composer looks and behaves identically everywhere: the
text input gets its own full-width row with the controls beneath it.
Expand All @@ -24,7 +24,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
import CircleStop from '@lucide/svelte/icons/circle-stop';
import Paperclip from '@lucide/svelte/icons/paperclip';
import ImagePlus from '@lucide/svelte/icons/image-plus';
import Spinner from '../../shared/Spinner.svelte';
Expand All @@ -49,12 +48,10 @@
imagePreviews?: Map<string, string>;
onAttachFiles?: (files: File[]) => void;
onRemoveImage?: (imageId: string) => void;
/** Session is running — attach/remove disabled, stop replaces send. */
/** Session is running — attach/remove disabled, send queues instead. */
isLive?: boolean;
sending?: boolean;
cancelling?: boolean;
onSend?: () => void;
onStop?: () => void;
/** Agent config picker rendered at the start of the control row. */
configPicker?: Snippet;
}
Expand All @@ -73,9 +70,7 @@
onRemoveImage,
isLive = false,
sending = false,
cancelling = false,
onSend,
onStop,
configPicker,
}: Props = $props();

Expand Down Expand Up @@ -153,39 +148,23 @@
</Button>
</span>
{/if}
{#if isLive}
<span class="inline-flex composer-send" title="Stop session">
<Button
variant="destructive"
class="h-auto min-h-9 w-9 shrink-0 rounded-[10px] px-0 [&_svg]:!size-4"
aria-label="Stop session"
onclick={onStop}
disabled={cancelling}
>
{#if cancelling}
<Spinner size={16} />
{:else}
<CircleStop size={16} />
{/if}
</Button>
</span>
{:else}
<span class="inline-flex composer-send" title="Send message">
<Button
variant="accent"
class="h-auto min-h-9 gap-1.5 px-4 py-1 text-sm max-[640px]:min-h-11"
onclick={onSend}
disabled={sending || !value.trim()}
>
{#if sending}
<Spinner size={14} />
Sending…
{:else}
Send
{/if}
</Button>
</span>
{/if}
<span class="inline-flex composer-send" title={isLive ? 'Queue message' : 'Send message'}>
<Button
variant="accent"
class="h-auto min-h-9 gap-1.5 px-4 py-1 text-sm max-[640px]:min-h-11"
onclick={onSend}
disabled={sending || !value.trim()}
>
{#if sending}
<Spinner size={14} />
Sending…
{:else if isLive}
Queue
{:else}
Send
{/if}
</Button>
</span>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@
scrollSelectedIntoView();
return;
}
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey) {
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
selectItem(filteredItems[selectedIndex]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,8 @@
}

function handleKeydown(e: KeyboardEvent) {
// Cmd+Enter to submit
if (e.key === 'Enter' && e.metaKey && canSubmit) {
// Cmd/Ctrl+Enter to submit
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canSubmit) {
e.preventDefault();
handleSubmit();
}
Expand Down
40 changes: 28 additions & 12 deletions apps/staged/src/lib/features/sessions/SessionChatPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
argument previews.

Features:
- Text input always available at the bottom
- Send button when idle, Stop button when running
- Message queue: hitting Enter while running persists a follow-up for backend drain
- Text input always available at the bottom (Cmd/Ctrl+Enter sends, Enter adds a newline)
- Send button when idle, Queue button when running; Stop lives on the Thinking row
- While a pipeline runs, the composer is replaced by a labeled Stop button
- Message queue: sending while running persists a follow-up for backend drain
- Copy button on every message
- Tool calls show name + args preview; expand to see output
- Fixed-size modal with proper scrolling
Expand Down Expand Up @@ -874,7 +875,8 @@
// =========================================================================

function handleInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.altKey) {
// Multiline input: plain Enter inserts a newline; Cmd/Ctrl+Enter sends.
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let Ctrl+Enter bypass hashtag selection

This makes Ctrl+Enter the documented send shortcut, but when the hashtag dropdown is open HashtagInput still consumes Enter whenever !shiftKey && !metaKey, so on Windows/Linux Ctrl+Enter selects the highlighted hashtag instead of reaching this handler. Messages with an open # suggestion therefore cannot be sent with the advertised shortcut unless the user first dismisses the dropdown or uses the button.

Useful? React with 👍 / 👎.

e.preventDefault();
handleSend();
}
Expand Down Expand Up @@ -1464,7 +1466,7 @@
.map((message) => message.id);
});

let isPipelinePrelude = $derived(isLive && !!session?.pipeline && grouped.length === 0);
let isPipelineRunning = $derived(isLive && !!session?.pipeline);

function insertSlashCommand(command: AcpCommand) {
inputText = `/${command.name}${command.inputHint ? ' ' : ''}`;
Expand Down Expand Up @@ -1961,6 +1963,22 @@
<div class="thinking" in:messageSlide>
<Spinner size={14} />
<span>Thinking…</span>
<Button
Comment on lines 1963 to +1966

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Stop available while waiting for the first response

When a live non-pipeline session has no transcript groups, rendering takes the earlier Waiting for response… center-state branch, so this new Thinking-row Stop button is never mounted; because the composer no longer renders Stop while isLive, users cannot cancel a running session until at least one message group appears. This is especially problematic if startup hangs before the first transcript message is written.

Useful? React with 👍 / 👎.

variant="destructive"
size="sm"
class="ml-auto"
title="Stop session"
aria-label="Stop session"
onclick={handleCancel}
disabled={cancelling}
>
{#if cancelling}
<Spinner size={14} />
{:else}
<CircleStop size={14} />
{/if}
Stop
</Button>
</div>
{/if}

Expand Down Expand Up @@ -2016,22 +2034,22 @@

<!-- Input area with queued messages and image previews -->
<div class="input-wrapper">
{#if isPipelinePrelude}
{#if isPipelineRunning}
<div class="pipeline-stop-area">
<span class="inline-flex" title="Stop workflow">
<Button
variant="destructive"
size="icon"
class="size-9 shrink-0 rounded-[10px] [&_svg]:!size-4"
size="sm"
aria-label="Stop workflow"
onclick={handleCancel}
disabled={cancelling}
>
{#if cancelling}
<Spinner size={16} />
<Spinner size={14} />
{:else}
<CircleStop size={16} />
<CircleStop size={14} />
{/if}
Stop
</Button>
</span>
</div>
Expand Down Expand Up @@ -2115,9 +2133,7 @@
onRemoveImage={removeReplyImage}
{isLive}
{sending}
{cancelling}
onSend={handleSend}
onStop={handleCancel}
>
{#snippet configPicker()}
<AcpFixedConfigPicker
Expand Down
62 changes: 54 additions & 8 deletions apps/staged/src/lib/features/sessions/SessionLauncher.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- Delete sessions from the database
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { listenToEvent, type UnlistenFn } from '../../transport';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
Expand All @@ -28,7 +28,6 @@
import type { AcpConfigPickerSelection } from '../agents/acpConfigSelection';
import { toast } from 'svelte-sonner';
import { sessionRegistry } from '../../stores/sessionRegistry.svelte';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';

interface Props {
Expand All @@ -48,6 +47,7 @@
let openModals = $state<Set<string>>(new Set());

let prompt = $state('');
let promptEl: HTMLTextAreaElement | null = $state(null);
let creating = $state(false);
let acpPickerSelection = $state<AcpConfigPickerSelection>({
providerId: null,
Expand Down Expand Up @@ -106,6 +106,7 @@
sessionRegistry.register(s.id, 'standalone', 'other');
sessions = [...sessions, s];
prompt = '';
tick().then(() => autoResize());
} catch (e) {
toast.error('Unable to start session', {
description: e instanceof Error ? e.message : String(e),
Expand Down Expand Up @@ -161,12 +162,26 @@
}

function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.altKey) {
// Multiline input: plain Enter inserts a newline; Cmd/Ctrl+Enter sends.
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleCreate();
}
}

function autoResize() {
if (!promptEl) return;
promptEl.style.height = 'auto';
promptEl.style.overflow = 'hidden';
const borderY = promptEl.offsetHeight - promptEl.clientHeight;
const maxHeight = 120;
const height = Math.min(promptEl.scrollHeight + borderY, maxHeight);
promptEl.style.height = height + 'px';
if (height >= maxHeight) {
promptEl.style.overflow = 'auto';
}
}

function handleAcpSelectionChange(selection: AcpConfigPickerSelection) {
acpPickerSelection = selection;
}
Expand All @@ -190,14 +205,15 @@

<!-- Create form -->
<div class="create-row">
<Input
type="text"
<textarea
bind:this={promptEl}
class="prompt-input"
placeholder="Session prompt…"
rows="1"
bind:value={prompt}
onkeydown={handleKeydown}
disabled={creating}
class="flex-1"
/>
oninput={autoResize}
disabled={creating}></textarea>
<AcpConfigPicker
disabled={creating}
triggerClass="h-7 max-w-[120px] border border-[var(--border-muted)] bg-[var(--bg-primary)]"
Expand Down Expand Up @@ -316,10 +332,40 @@
/* Create row */
.create-row {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 10px 14px;
}

.prompt-input {
flex: 1;
min-width: 0;
min-height: 36px;
max-height: 120px;
padding: 8px 10px;
border: 1px solid var(--border-muted);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
font-family: inherit;
font-size: var(--size-sm);
line-height: 18px;
resize: none;
outline: none;
}

.prompt-input::placeholder {
color: var(--text-faint);
}

.prompt-input:focus {
border-color: var(--ui-accent);
}

.prompt-input:disabled {
opacity: 0.5;
}

/* Session list */
.session-list {
max-height: 240px;
Expand Down