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
85 changes: 47 additions & 38 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { $composerAttachments, clearComposerAttachments, type ComposerAttachment
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
promoteQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
Expand Down Expand Up @@ -136,12 +137,6 @@ export function ChatBar({
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const drainingQueueRef = useRef(false)
// Set when the user explicitly interrupts the running turn via the Stop
// button (busy + empty composer). It suppresses the next busy→false
// auto-drain so an explicit Stop actually halts instead of immediately
// firing the head of the queue. The queue is preserved; the user resumes
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
const userInterruptedRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)

const [urlOpen, setUrlOpen] = useState(false)
Expand Down Expand Up @@ -724,7 +719,22 @@ export function ChatBar({
return
}

// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
// never a stray Enter after sending. With a payload, submitDraft queues it.
if (busy && !hasComposerPayload) {
return
}

submitDraft()

return
}

// Esc interrupts the running turn (Stop-button parity).
if (event.key === 'Escape' && busy) {
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
}

Expand Down Expand Up @@ -978,41 +988,40 @@ export function ChatBar({
)

const sendQueuedNow = useCallback(
(id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)),
[queueEdit, runDrain]
(id: string) => {
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
return false
}

if (busy) {
// Promote to the head, then interrupt. The gateway always emits a
// settle (message.complete + session.info running:false) when the
// turn unwinds, and the busy→false auto-drain below sends this entry.
promoteQueuedPrompt(activeQueueSessionKey, id)
triggerHaptic('selection')
void Promise.resolve(onCancel())

return true
}

return runDrain(entries => entries.find(e => e.id === id))
},
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
)

// Auto-drain on busy → false (turn settled). An explicit user interrupt
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
// the user asked to halt, so we must not immediately re-send the queue.
// The queued turns stay intact and the user resumes them on demand.
// Auto-drain on busy → false (turn settled). Queued turns always flow once
// the session is idle again — whether the turn finished naturally or the
// user interrupted it. Interrupting to reach a queued message is the whole
// point of the queue, so we never suppress the drain. To cancel queued
// turns, the user deletes them from the panel.
useEffect(() => {
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy

// Clear the interrupt latch when a new turn starts (false → true). This
// guards the sub-frame race where a Stop click lands after busy already
// flipped false (button not yet unmounted): the stale latch can no longer
// survive into the next turn and wrongly suppress its natural auto-drain.
if (busy && !wasBusy) {
userInterruptedRef.current = false

return
}

const interrupted = userInterruptedRef.current

// Consume the interrupt latch on any settle so a later natural completion
// is not wrongly suppressed.
if (!busy && wasBusy && interrupted) {
userInterruptedRef.current = false
}

if (
shouldAutoDrainOnSettle({
isBusy: busy,
queueLength: queuedPrompts.length,
userInterrupted: interrupted,
wasBusy
})
) {
Expand Down Expand Up @@ -1053,12 +1062,8 @@ export function ChatBar({
} else if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button: an explicit interrupt must actually halt the running
// turn. Mark the interrupt so the busy→false auto-drain effect skips
// re-sending the queue — otherwise a queued follow-up would fire the
// instant we cancel and Stop would appear to "never work". Queued
// turns are preserved; the user sends them on demand.
userInterruptedRef.current = true
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
Expand Down Expand Up @@ -1284,7 +1289,11 @@ export function ChatBar({
)}
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
{activeQueueSessionKey && queuedPrompts.length > 0 && (
<div className="relative z-6 mb-1 px-0.5">
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on
// queue). Overlaps -mb-2 onto the surface's top border for a shared
// edge; capped + scrollable. Overlays the chat instead of pushing it.
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
<QueuePanel
busy={busy}
editingId={queueEdit?.entryId ?? null}
Expand Down
17 changes: 9 additions & 8 deletions apps/desktop/src/app/chat/composer/queue-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(true)

if (entries.length === 0) {
return null
}

return (
<div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]">
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1">
<button
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
className="flex w-full items-center gap-1.5 px-2 py-0.5 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
onClick={() => setCollapsed(open => !open)}
type="button"
>
Expand All @@ -41,15 +41,16 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
</button>

{!collapsed && (
<div className="space-y-0.5 px-1.5 pb-0.5">
<div className="space-y-0.5 px-1 pb-0.5">
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow

return (
<div
className={cn(
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-1',
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
Expand Down Expand Up @@ -97,11 +98,11 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<Pencil size={11} />
</Button>
</Tip>
<Tip label={c.sendQueuedNow}>
<Tip label={sendLabel}>
<Button
aria-label={c.sendQueuedNow}
aria-label={sendLabel}
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
Expand Down
Loading
Loading