Skip to content

Commit ee0962c

Browse files
tlgimenesclaude
andcommitted
feat(chat): refine queue panel — hide running, enable input mid-run, SSE refresh
Address three UI remarks on the thread-queue panel (#4155): 1. The queue panel now lists only *waiting* (ENQUEUED) messages. The running/PENDING head is excluded — it's already rendered in the chat body, so showing it in the queue too was duplicative. 2. The composer is no longer disabled while a run is in progress. A draft sends even mid-run (the message enqueues behind the running gate; concurrency=1 serializes the thread), so users can stack follow-ups. New pure helper `resolveComposerAction({hasDraft,isStreaming, isRunInProgress})` → send | stop | disabled, unit-tested; the primary button flips Stop→Send the moment there's a draft. 3. The queue is now refreshed from SSE, not a 3s poll. Dropped `refetchInterval`; the query re-fetches via `KEYS.threadQueue` invalidation on send, on cancel, and on every `conn.status` run start/end edge (the SSE stream the chat already consumes). Verified live against a seeded stranded gate (1 PENDING head + 2 ENQUEUED): panel shows only the 2 queued, input editable mid-run, per-item cancel + refresh, and no recurring /queue/ polling in the network log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f6d500f commit ee0962c

6 files changed

Lines changed: 147 additions & 66 deletions

File tree

apps/mesh/src/web/components/chat/chat-context.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,8 +999,28 @@ export function ActiveTaskProvider({
999999
onToolCall: (event) => cbRef.current.onToolCall(event as never),
10001000
};
10011001
conn.observer = observer;
1002+
1003+
// Refresh the pending-message queue on every SSE run start/end edge. A
1004+
// gate dequeue flips conn.status idle→active (the next queued message
1005+
// began streaming); a finish flips active→idle (the slot freed). This is
1006+
// the SSE-driven replacement for the old 3s poll — the queue panel updates
1007+
// exactly when the thread gate advances, off the same stream the chat uses.
1008+
const isActiveStatus = (k: ConnStatus["kind"]) =>
1009+
k === "submitted" || k === "streaming";
1010+
let prevActive = isActiveStatus(conn.status.get().kind);
1011+
const unsubStatus = conn.status.subscribe(() => {
1012+
const active = isActiveStatus(conn.status.get().kind);
1013+
if (active === prevActive) return;
1014+
prevActive = active;
1015+
const cb = cbRef.current;
1016+
cb.queryClient.invalidateQueries({
1017+
queryKey: KEYS.threadQueue(cb.taskId),
1018+
});
1019+
});
1020+
10021021
return () => {
10031022
if (conn.observer === observer) conn.observer = null;
1023+
unsubStatus();
10041024
};
10051025
}, [conn]);
10061026

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { resolveComposerAction } from "./composer-action";
3+
4+
describe("resolveComposerAction", () => {
5+
it("sends when there is a draft, even while a run streams (enqueue)", () => {
6+
expect(
7+
resolveComposerAction({
8+
hasDraft: true,
9+
isStreaming: true,
10+
isRunInProgress: false,
11+
}),
12+
).toBe("send");
13+
});
14+
15+
it("sends when there is a draft and a hosted run is in progress", () => {
16+
expect(
17+
resolveComposerAction({
18+
hasDraft: true,
19+
isStreaming: false,
20+
isRunInProgress: true,
21+
}),
22+
).toBe("send");
23+
});
24+
25+
it("sends when there is a draft and nothing is running", () => {
26+
expect(
27+
resolveComposerAction({
28+
hasDraft: true,
29+
isStreaming: false,
30+
isRunInProgress: false,
31+
}),
32+
).toBe("send");
33+
});
34+
35+
it("offers stop when streaming with no draft", () => {
36+
expect(
37+
resolveComposerAction({
38+
hasDraft: false,
39+
isStreaming: true,
40+
isRunInProgress: false,
41+
}),
42+
).toBe("stop");
43+
});
44+
45+
it("offers stop when a hosted run is in progress with no draft", () => {
46+
expect(
47+
resolveComposerAction({
48+
hasDraft: false,
49+
isStreaming: false,
50+
isRunInProgress: true,
51+
}),
52+
).toBe("stop");
53+
});
54+
55+
it("is disabled when idle with no draft", () => {
56+
expect(
57+
resolveComposerAction({
58+
hasDraft: false,
59+
isStreaming: false,
60+
isRunInProgress: false,
61+
}),
62+
).toBe("disabled");
63+
});
64+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/** The composer's primary-button action. */
2+
export type ComposerAction = "send" | "stop" | "disabled";
3+
4+
/**
5+
* Decide the composer's primary-button action.
6+
*
7+
* A draft always sends — even while a run streams or a hosted run is in
8+
* progress — because a second message enqueues behind the running one on the
9+
* thread gate (concurrency=1 serializes them). With no draft, an active run
10+
* offers stop; otherwise the button is disabled. Pure + total.
11+
*/
12+
export function resolveComposerAction(state: {
13+
hasDraft: boolean;
14+
isStreaming: boolean;
15+
isRunInProgress: boolean;
16+
}): ComposerAction {
17+
if (state.hasDraft) return "send";
18+
if (state.isStreaming || state.isRunInProgress) return "stop";
19+
return "disabled";
20+
}

apps/mesh/src/web/components/chat/input.tsx

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { useVoiceInput } from "@/web/hooks/use-voice-input.ts";
6868
import { VoiceWaveform } from "./voice-input";
6969
import { shouldRenderInlineModeRow } from "./input-mode-row";
7070
import { ThreadQueuePanel } from "./queue-panel";
71+
import { resolveComposerAction } from "./composer-action";
7172

7273
// ============================================================================
7374
// useWindowFileDrop - Reusable hook for window-level file drag & drop
@@ -406,23 +407,25 @@ export function ChatInput({
406407
// model's context is much fuller than it actually is.
407408
const lastTotalTokens = lastUsage?.contextTokens ?? 0;
408409

409-
const canSubmit =
410-
!isStreaming && !isModelsLoading && !isTiptapDocEmpty(tiptapDoc);
411-
412-
const showStopOrCancel = isStreaming || isRunInProgress;
410+
// A draft sends even mid-run — the new message enqueues behind the running
411+
// gate (concurrency=1 serializes the thread). Stop is offered only when
412+
// there's nothing to send. `canSubmit`/`showStopOrCancel` are kept as the
413+
// names the button/render logic below already references.
414+
const hasDraft = !isModelsLoading && !isTiptapDocEmpty(tiptapDoc);
415+
const composerAction = resolveComposerAction({
416+
hasDraft,
417+
isStreaming,
418+
isRunInProgress,
419+
});
420+
const canSubmit = composerAction === "send";
421+
const showStopOrCancel = composerAction === "stop";
413422
const showInlineModeRow = shouldRenderInlineModeRow({
414423
messageCount: messages.length,
415424
showConnectionsBanner,
416425
});
417426
const handleSubmit = (e?: FormEvent) => {
418427
e?.preventDefault();
419-
if (isStreaming) {
420-
track("chat_message_stopped", { thread_id: taskId });
421-
stop();
422-
} else if (isRunInProgress) {
423-
track("chat_message_stopped", { thread_id: taskId });
424-
stop();
425-
} else if (canSubmit && tiptapDoc) {
428+
if (composerAction === "send" && tiptapDoc) {
426429
track("chat_message_sent", {
427430
thread_id: taskId || null,
428431
mode: chatMode,
@@ -438,6 +441,9 @@ export function ChatInput({
438441
}
439442
clearChatDraft(sessionStorage, locator, draftKey);
440443
setTiptapDoc(undefined);
444+
} else if (composerAction === "stop") {
445+
track("chat_message_stopped", { thread_id: taskId });
446+
stop();
441447
}
442448
};
443449

@@ -462,17 +468,14 @@ export function ChatInput({
462468
{stream && taskCtx && <ChatHighlight />}
463469

464470
{stream && taskCtx && taskId ? (
465-
<ThreadQueuePanel
466-
taskId={taskId}
467-
active={isStreaming || isRunInProgress}
468-
/>
471+
<ThreadQueuePanel taskId={taskId} />
469472
) : null}
470473

471474
<TiptapProvider
472475
key={taskId}
473476
tiptapDoc={tiptapDoc}
474477
setTiptapDoc={setTiptapDoc}
475-
disabled={isStreaming}
478+
disabled={false}
476479
enterToSubmit={true}
477480
onSubmit={handleSubmit}
478481
>
@@ -490,7 +493,7 @@ export function ChatInput({
490493
<div className="group/input relative flex flex-col gap-2 flex-1">
491494
<TiptapInput
492495
ref={tiptapRef}
493-
disabled={isStreaming || voice.status === "recording"}
496+
disabled={voice.status === "recording"}
494497
virtualMcpId={selectedVirtualMcp?.id ?? decopilotId}
495498
showFileUploader={true}
496499
selectedModel={selectedModel}
@@ -703,8 +706,7 @@ export function ChatInput({
703706
if (showStopOrCancel) {
704707
e.preventDefault();
705708
e.stopPropagation();
706-
if (isStreaming) stop();
707-
else stop();
709+
stop();
708710
}
709711
}}
710712
variant={
@@ -719,11 +721,11 @@ export function ChatInput({
719721
"bg-muted text-muted-foreground hover:bg-muted hover:text-muted-foreground cursor-not-allowed",
720722
)}
721723
title={
722-
isStreaming
723-
? "Stop generating"
724-
: isRunInProgress
725-
? "Cancel run"
726-
: "Send message (Enter)"
724+
composerAction === "stop"
725+
? isStreaming
726+
? "Stop generating"
727+
: "Cancel run"
728+
: "Send message (Enter)"
727729
}
728730
>
729731
{showStopOrCancel ? (

apps/mesh/src/web/components/chat/queue-panel.tsx

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,31 @@
11
import { Button } from "@deco/ui/components/button.tsx";
2-
import { cn } from "@deco/ui/lib/utils.ts";
32
import { X } from "@untitledui/icons";
43
import { useCancelQueuedMessage, useThreadQueue } from "./use-thread-queue";
54

65
/**
7-
* Pending-message queue above the composer. Lists the thread's running head +
8-
* queued messages; each row cancels its own gate workflow. Hidden when empty.
6+
* Pending-message queue above the composer. Lists only the messages *waiting*
7+
* behind the active run — the running head is excluded here because it's
8+
* already rendered in the chat body. Each row cancels its own gate workflow.
9+
* Hidden when nothing is queued.
910
*/
10-
export function ThreadQueuePanel({
11-
taskId,
12-
active,
13-
}: {
14-
taskId: string;
15-
active: boolean;
16-
}) {
17-
const { items } = useThreadQueue(taskId, { active });
11+
export function ThreadQueuePanel({ taskId }: { taskId: string }) {
12+
const { items } = useThreadQueue(taskId);
1813
const cancel = useCancelQueuedMessage(taskId);
19-
if (items.length === 0) return null;
20-
21-
const queuedCount = items.filter((i) => i.status === "queued").length;
14+
const queued = items.filter((i) => i.status === "queued");
15+
if (queued.length === 0) return null;
2216

2317
return (
2418
<div className="mb-1.5 rounded-xl border border-border bg-muted/50 p-2 text-sm">
2519
<div className="px-1 pb-1 text-xs text-muted-foreground">
26-
{queuedCount > 0
27-
? `${queuedCount} queued message${queuedCount === 1 ? "" : "s"}`
28-
: "Running"}
20+
{`${queued.length} queued message${queued.length === 1 ? "" : "s"}`}
2921
</div>
3022
<ul className="flex flex-col gap-1">
31-
{items.map((item) => (
23+
{queued.map((item) => (
3224
<li
3325
key={item.workflowId}
3426
className="flex items-center gap-2 rounded-lg bg-background px-2 py-1.5"
3527
>
36-
<span
37-
className={cn(
38-
"size-1.5 shrink-0 rounded-full",
39-
item.status === "running"
40-
? "bg-primary animate-pulse"
41-
: "bg-muted-foreground/40",
42-
)}
43-
/>
28+
<span className="size-1.5 shrink-0 rounded-full bg-muted-foreground/40" />
4429
<span className="min-w-0 flex-1 truncate text-foreground">
4530
{item.text || (
4631
<span className="text-muted-foreground">(no text)</span>
@@ -51,9 +36,7 @@ export function ThreadQueuePanel({
5136
variant="ghost"
5237
size="icon"
5338
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
54-
title={
55-
item.status === "running" ? "Cancel run" : "Remove from queue"
56-
}
39+
title="Remove from queue"
5740
onClick={() => cancel(item.workflowId)}
5841
>
5942
<X size={14} />

apps/mesh/src/web/components/chat/use-thread-queue.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,17 @@ export interface QueueItemDTO {
1212
}
1313

1414
/**
15-
* Poll this thread's pending gate queue. Polls every 3s while the run is active
16-
* or items exist (cheap, bounded), otherwise idles (re-fetched on send via the
17-
* KEYS.threadQueue invalidation in chat-context). No useEffect (lint ban).
15+
* Read this thread's pending gate queue. Event-driven, not polled: the query
16+
* is re-fetched via `KEYS.threadQueue` invalidation on send (chat-context),
17+
* on cancel (below), and on every SSE run start/end edge (the `conn.status`
18+
* subscription in chat-context). No timer, no useEffect (lint ban).
1819
*/
19-
export function useThreadQueue(
20-
taskId: string,
21-
opts: { active: boolean },
22-
): { items: QueueItemDTO[] } {
20+
export function useThreadQueue(taskId: string): { items: QueueItemDTO[] } {
2321
const { org } = useProjectContext();
2422
const { data } = useQuery({
2523
queryKey: KEYS.threadQueue(taskId),
2624
enabled: Boolean(taskId),
2725
staleTime: 0,
28-
refetchInterval: (query) => {
29-
const n =
30-
(query.state.data as { items?: QueueItemDTO[] } | undefined)?.items
31-
?.length ?? 0;
32-
return opts.active || n > 0 ? 3000 : false;
33-
},
3426
queryFn: async (): Promise<{ items: QueueItemDTO[] }> => {
3527
const res = await fetch(`/api/${org.slug}/decopilot/queue/${taskId}`, {
3628
credentials: "include",

0 commit comments

Comments
 (0)