Skip to content

Commit a3adc80

Browse files
authored
feat: Implement the ability to queue messages in chat (#621)
#360
1 parent b1223c4 commit a3adc80

8 files changed

Lines changed: 272 additions & 8 deletions

File tree

apps/twig/src/renderer/features/message-editor/stores/draftStore.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface DraftState {
2121
contexts: Record<SessionId, EditorContext>;
2222
commands: Record<SessionId, AvailableCommand[]>;
2323
focusRequested: Record<SessionId, number>;
24+
pendingContent: Record<SessionId, EditorContent>;
2425
_hasHydrated: boolean;
2526
}
2627

@@ -39,6 +40,8 @@ export interface DraftActions {
3940
clearCommands: (sessionId: SessionId) => void;
4041
requestFocus: (sessionId: SessionId) => void;
4142
clearFocusRequest: (sessionId: SessionId) => void;
43+
setPendingContent: (sessionId: SessionId, content: EditorContent) => void;
44+
clearPendingContent: (sessionId: SessionId) => void;
4245
}
4346

4447
type DraftStore = DraftState & { actions: DraftActions };
@@ -50,6 +53,7 @@ export const useDraftStore = create<DraftStore>()(
5053
contexts: {},
5154
commands: {},
5255
focusRequested: {},
56+
pendingContent: {},
5357
_hasHydrated: false,
5458

5559
actions: {
@@ -110,6 +114,16 @@ export const useDraftStore = create<DraftStore>()(
110114
set((state) => {
111115
delete state.focusRequested[sessionId];
112116
}),
117+
118+
setPendingContent: (sessionId, content) =>
119+
set((state) => {
120+
state.pendingContent[sessionId] = content;
121+
}),
122+
123+
clearPendingContent: (sessionId) =>
124+
set((state) => {
125+
delete state.pendingContent[sessionId];
126+
}),
113127
},
114128
})),
115129
{

apps/twig/src/renderer/features/message-editor/tiptap/useDraftSync.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ export function useDraftSync(
110110

111111
const draftActions = useDraftStore((s) => s.actions);
112112
const draft = useDraftStore((s) => s.drafts[sessionId] ?? null);
113+
const pendingContent = useDraftStore(
114+
(s) => s.pendingContent[sessionId] ?? null,
115+
);
113116
const hasHydrated = useDraftStore((s) => s._hasHydrated);
114117

115118
// Reset restoration flag when sessionId changes (e.g., navigating between tasks)
@@ -149,6 +152,15 @@ export function useDraftSync(
149152
}
150153
}, [hasHydrated, draft, editor]);
151154

155+
// Handle pending content (e.g., restoring queued messages after cancel)
156+
useLayoutEffect(() => {
157+
if (!editor || !pendingContent) return;
158+
159+
editor.commands.setContent(editorContentToTiptapJson(pendingContent));
160+
editor.commands.focus("end");
161+
draftActions.clearPendingContent(sessionId);
162+
}, [editor, pendingContent, sessionId, draftActions]);
163+
152164
const saveDraft = useCallback(
153165
(e: Editor) => {
154166
// Don't save until store has hydrated from storage

apps/twig/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
304304

305305
const submit = useCallback(() => {
306306
if (!editor) return;
307-
if (disabled || isLoading) return;
307+
if (disabled) return;
308308

309309
const text = editor.getText().trim();
310310
if (!text) return;
@@ -314,9 +314,15 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
314314
toast.error("Bash mode is not supported in cloud sessions");
315315
return;
316316
}
317+
// Bash mode requires immediate execution, can't be queued
318+
if (isLoading) {
319+
toast.error("Cannot run shell commands while agent is generating");
320+
return;
321+
}
317322
const command = text.slice(1).trim();
318323
if (command) callbackRefs.current.onBashCommand?.(command);
319324
} else {
325+
// Normal prompts can be queued when loading
320326
const content = draft.getContent();
321327
callbackRefs.current.onSubmit?.(contentToXml(content));
322328
}

apps/twig/src/renderer/features/sessions/components/ConversationView.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import type {
22
ContentBlock,
33
SessionNotification,
44
} from "@agentclientprotocol/sdk";
5-
import { usePendingPermissionsForTask } from "@features/sessions/stores/sessionStore";
5+
import {
6+
usePendingPermissionsForTask,
7+
useQueuedMessagesForTask,
8+
useSessionActions,
9+
} from "@features/sessions/stores/sessionStore";
610
import type { SessionUpdate, ToolCall } from "@features/sessions/types";
711
import { ArrowDown, XCircle } from "@phosphor-icons/react";
812
import { Box, Button, Flex, Text } from "@radix-ui/themes";
@@ -24,6 +28,7 @@ import {
2428
import { GitActionMessage, parseGitActionMessage } from "./GitActionMessage";
2529
import { GitActionResult } from "./GitActionResult";
2630
import { SessionFooter } from "./SessionFooter";
31+
import { QueuedMessageView } from "./session-update/QueuedMessageView";
2732
import {
2833
type RenderItem,
2934
SessionUpdateView,
@@ -77,6 +82,10 @@ export function ConversationView({
7782
const pendingPermissions = usePendingPermissionsForTask(taskId ?? "");
7883
const pendingPermissionsCount = pendingPermissions.size;
7984

85+
// Get queued messages and actions
86+
const queuedMessages = useQueuedMessagesForTask(taskId);
87+
const { removeQueuedMessage } = useSessionActions();
88+
8089
const isNearBottomRef = useRef(true);
8190
const prevItemsLengthRef = useRef(0);
8291
const prevPendingCountRef = useRef(0);
@@ -140,6 +149,13 @@ export function ConversationView({
140149
<UserShellExecuteView key={item.id} item={item} />
141150
),
142151
)}
152+
{queuedMessages.map((msg) => (
153+
<QueuedMessageView
154+
key={msg.id}
155+
message={msg}
156+
onRemove={() => taskId && removeQueuedMessage(taskId, msg.id)}
157+
/>
158+
))}
143159
</div>
144160
<SessionFooter
145161
isPromptPending={isPromptPending}
@@ -148,6 +164,7 @@ export function ConversationView({
148164
lastTurn?.isComplete ? lastTurn.durationMs : null
149165
}
150166
lastStopReason={lastTurn?.stopReason}
167+
queuedCount={queuedMessages.length}
151168
/>
152169
</div>
153170
{showScrollButton && (

apps/twig/src/renderer/features/sessions/components/SessionFooter.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Text } from "@radix-ui/themes";
1+
import { Box, Flex, Text } from "@radix-ui/themes";
22

33
import { formatDuration, GeneratingIndicator } from "./GeneratingIndicator";
44

@@ -7,18 +7,27 @@ interface SessionFooterProps {
77
promptStartedAt?: number | null;
88
lastGenerationDuration: number | null;
99
lastStopReason?: string;
10+
queuedCount?: number;
1011
}
1112

1213
export function SessionFooter({
1314
isPromptPending,
1415
promptStartedAt,
1516
lastGenerationDuration,
1617
lastStopReason,
18+
queuedCount = 0,
1719
}: SessionFooterProps) {
1820
if (isPromptPending) {
1921
return (
2022
<Box className="pt-3 pb-1">
21-
<GeneratingIndicator startedAt={promptStartedAt} />
23+
<Flex align="center" gap="2">
24+
<GeneratingIndicator startedAt={promptStartedAt} />
25+
{queuedCount > 0 && (
26+
<Text size="1" color="gray">
27+
({queuedCount} queued)
28+
</Text>
29+
)}
30+
</Flex>
2231
</Box>
2332
);
2433
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer";
2+
import type { QueuedMessage } from "@features/sessions/stores/sessionStore";
3+
import { Clock, X } from "@phosphor-icons/react";
4+
import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes";
5+
6+
interface QueuedMessageViewProps {
7+
message: QueuedMessage;
8+
onRemove: () => void;
9+
}
10+
11+
export function QueuedMessageView({
12+
message,
13+
onRemove,
14+
}: QueuedMessageViewProps) {
15+
return (
16+
<Box
17+
className="border-l-2 border-dashed bg-gray-2 py-2 pr-2 pl-3 opacity-70"
18+
style={{ borderColor: "var(--gray-8)" }}
19+
>
20+
<Flex justify="between" align="start" gap="2">
21+
<Box className="flex-1 font-medium [&>*:last-child]:mb-0">
22+
<MarkdownRenderer content={message.content} />
23+
</Box>
24+
<Tooltip content="Remove from queue">
25+
<IconButton
26+
size="1"
27+
variant="ghost"
28+
color="gray"
29+
onClick={onRemove}
30+
className="shrink-0"
31+
>
32+
<X size={14} />
33+
</IconButton>
34+
</Tooltip>
35+
</Flex>
36+
<Flex align="center" gap="1" mt="1">
37+
<Clock size={12} className="text-gray-9" />
38+
<Text size="1" color="gray">
39+
Queued
40+
</Text>
41+
</Flex>
42+
</Box>
43+
);
44+
}

0 commit comments

Comments
 (0)