Skip to content

Commit 67a87b7

Browse files
fix(chat): show parallel card lifecycle states
1 parent 26f76d0 commit 67a87b7

9 files changed

Lines changed: 144 additions & 25 deletions
32.9 KB
Loading

apps/chat/components/assistant-message.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,14 @@ const PureAssistantMessage = ({
2626
const isPendingLastMessage =
2727
messageId === lastMessageId &&
2828
(status === "submitted" || status === "streaming");
29-
const shouldHideCompletionActions = isLoading || isPendingLastMessage;
29+
const activeStreamId = metadata.activeStreamId;
30+
const hasActiveResponse = activeStreamId !== null;
31+
const shouldHideCompletionActions =
32+
isLoading || hasActiveResponse || isPendingLastMessage;
3033
const isReconnectingToMessageStream =
31-
metadata.activeStreamId !== null && status === "submitted";
34+
hasActiveResponse &&
35+
!activeStreamId.startsWith("pending:") &&
36+
status === "submitted";
3237

3338
if (!chatId || isReconnectingToMessageStream) {
3439
return null;

apps/chat/components/multimodal-input.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ import {
4646
} from "@/lib/parallel-chat-requests";
4747
import { useStartProvisionalChat } from "@/lib/start-provisional-chat";
4848
import { useChatActions, useChatStoreApi } from "@/lib/stores/base";
49-
import { useLastMessageId } from "@/lib/stores/hooks-base";
49+
import {
50+
useLastMessageId,
51+
useLastMessageMetadata,
52+
} from "@/lib/stores/hooks-base";
5053
import { useAddMessageToTree } from "@/lib/stores/hooks-threads";
5154
import { ANONYMOUS_LIMITS } from "@/lib/types/anonymous";
5255
import { cn } from "@/lib/utils";
@@ -57,6 +60,7 @@ import { useTRPC } from "@/trpc/react";
5760
import { ConnectorsDropdown } from "./connectors-dropdown";
5861
import { LexicalChatInput } from "./lexical-chat-input";
5962
import { ModelSelector } from "./model-selector";
63+
import { getResponseAwareStatus } from "./parallel-response-status";
6064
import { ResponsiveTools } from "./responsive-tools";
6165
import {
6266
DropdownMenu,
@@ -117,12 +121,18 @@ function PureMultimodalInput({
117121
const currentRoute = useCurrentChatRoute();
118122
const startProvisionalChat = useStartProvisionalChat(chatId);
119123
const {
124+
setStatus,
120125
setMessages,
121126
sendMessage,
122127
startRun,
123128
stop: stopHelper,
124129
} = useChatActions<ChatMessage>();
125130
const lastMessageId = useLastMessageId();
131+
const lastMessageMetadata = useLastMessageMetadata();
132+
const responseAwareStatus = getResponseAwareStatus(
133+
status,
134+
lastMessageMetadata ? { metadata: lastMessageMetadata } : null
135+
);
126136
const {
127137
editorRef,
128138
selectedTool,
@@ -223,7 +233,7 @@ function PureMultimodalInput({
223233
if (isModelDisallowedForAnonymous) {
224234
return { enabled: false, message: "Log in to use this model" };
225235
}
226-
if (status !== "ready" && status !== "error") {
236+
if (responseAwareStatus !== "ready" && responseAwareStatus !== "error") {
227237
return {
228238
enabled: false,
229239
message: "Please wait for the model to finish its response!",
@@ -248,7 +258,7 @@ function PureMultimodalInput({
248258
isModelDisallowedForAnonymous,
249259
isParallelModelRequest,
250260
session?.user,
251-
status,
261+
responseAwareStatus,
252262
uploadQueue.length,
253263
]);
254264

@@ -385,6 +395,7 @@ function PureMultimodalInput({
385395
}
386396

387397
if (primaryRequest) {
398+
setStatus("submitted");
388399
sendMessage(message, {
389400
body: {
390401
...createParallelRequestBody(primaryRequest, true),
@@ -423,6 +434,7 @@ function PureMultimodalInput({
423434
toast.error("Failed to complete all parallel responses");
424435
});
425436
} else {
437+
setStatus("submitted");
426438
sendMessage(
427439
message,
428440
currentRoute.projectId
@@ -454,6 +466,7 @@ function PureMultimodalInput({
454466
parallelResponsesEnabled,
455467
selectedTool,
456468
sendMessage,
469+
setStatus,
457470
startProvisionalChat,
458471
startRun,
459472
trimMessagesInEditMode,
@@ -531,7 +544,7 @@ function PureMultimodalInput({
531544

532545
const handlePaste = useCallback(
533546
async (event: React.ClipboardEvent) => {
534-
if (status !== "ready") {
547+
if (responseAwareStatus !== "ready") {
535548
return;
536549
}
537550

@@ -589,7 +602,7 @@ function PureMultimodalInput({
589602
[
590603
setAttachments,
591604
processFiles,
592-
status,
605+
responseAwareStatus,
593606
session,
594607
uploadFile,
595608
attachmentsEnabled,
@@ -644,7 +657,7 @@ function PureMultimodalInput({
644657
}
645658
},
646659
noClick: true, // Prevent click to open file dialog since we have the button
647-
disabled: status !== "ready" || !attachmentsEnabled,
660+
disabled: responseAwareStatus !== "ready" || !attachmentsEnabled,
648661
noDrag: !attachmentsEnabled,
649662
accept: acceptedTypes,
650663
});
@@ -758,7 +771,7 @@ function PureMultimodalInput({
758771
selectedModelSelection={selectedModelSelection}
759772
selectedTool={selectedTool}
760773
setSelectedTool={setSelectedTool}
761-
status={status}
774+
status={responseAwareStatus}
762775
submission={submission}
763776
submitForm={submitForm}
764777
/>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "vitest";
3+
import {
4+
getParallelResponseLifecycle,
5+
getResponseAwareStatus,
6+
getStatusLabel,
7+
} from "./parallel-response-status";
8+
9+
function createAssistantMessage(activeStreamId: string | null) {
10+
return {
11+
metadata: { activeStreamId },
12+
};
13+
}
14+
15+
describe("parallel response card status", () => {
16+
it("keeps queued and streaming responses in a loading state", () => {
17+
assert.equal(getParallelResponseLifecycle(null), "queued");
18+
assert.equal(
19+
getParallelResponseLifecycle(
20+
createAssistantMessage("pending:assistant-1")
21+
),
22+
"queued"
23+
);
24+
assert.equal(
25+
getParallelResponseLifecycle(createAssistantMessage("stream-1")),
26+
"generating"
27+
);
28+
assert.equal(getStatusLabel(true, "queued"), "Generating...");
29+
assert.equal(getStatusLabel(false, "generating"), "Generating...");
30+
});
31+
32+
it("shows completion only after the stream marker is cleared", () => {
33+
assert.equal(
34+
getParallelResponseLifecycle(createAssistantMessage(null)),
35+
"complete"
36+
);
37+
assert.equal(getStatusLabel(true, "complete"), "Selected");
38+
assert.equal(getStatusLabel(false, "complete"), "Task completed");
39+
});
40+
41+
it("keeps selected pending responses stoppable", () => {
42+
assert.equal(
43+
getResponseAwareStatus(
44+
"ready",
45+
createAssistantMessage("pending:assistant-1")
46+
),
47+
"submitted"
48+
);
49+
assert.equal(
50+
getResponseAwareStatus("ready", createAssistantMessage("stream-1")),
51+
"streaming"
52+
);
53+
assert.equal(
54+
getResponseAwareStatus("ready", createAssistantMessage(null)),
55+
"ready"
56+
);
57+
});
58+
});

apps/chat/components/parallel-response-cards.tsx

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { useParallelGroupInfo } from "@/lib/stores/hooks-threads";
1515
import { cn } from "@/lib/utils";
1616
import { useChatInput } from "@/providers/chat-input-provider";
1717
import { useChatModels } from "@/providers/chat-models-provider";
18+
import {
19+
getParallelResponseLifecycle,
20+
getStatusLabel,
21+
} from "./parallel-response-status";
1822

1923
function getEffectiveModelId(
2024
message: {
@@ -38,16 +42,6 @@ function getModelOrderIndex(
3842
return index === -1 ? Number.POSITIVE_INFINITY : index;
3943
}
4044

41-
function getStatusLabel(isSelected: boolean, isStreaming: boolean): string {
42-
if (isSelected) {
43-
return "Selected";
44-
}
45-
if (isStreaming) {
46-
return "Generating...";
47-
}
48-
return "Task completed";
49-
}
50-
5145
function PureParallelResponseCards({ messageId }: { messageId: string }) {
5246
const message = useMessageById<ChatMessage>(messageId);
5347
const parallelGroupInfo = useParallelGroupInfo(messageId);
@@ -131,10 +125,9 @@ function PureParallelResponseCards({ messageId }: { messageId: string }) {
131125
? (getModelById(modelId)?.name ?? modelId)
132126
: "Model";
133127
const isSelected = selectedParallelIndex === slot.parallelIndex;
134-
const isStreaming = slot.message
135-
? slot.message.metadata.activeStreamId !== null
136-
: true;
137-
const statusLabel = getStatusLabel(isSelected, isStreaming);
128+
const lifecycle = getParallelResponseLifecycle(slot.message);
129+
const isLoading = lifecycle !== "complete";
130+
const statusLabel = getStatusLabel(isSelected, lifecycle);
138131

139132
return (
140133
<Button
@@ -157,7 +150,7 @@ function PureParallelResponseCards({ messageId }: { messageId: string }) {
157150
>
158151
<span className="font-medium text-sm">{modelName}</span>
159152
<span className="flex items-center gap-1 text-muted-foreground text-xs">
160-
{isStreaming ? (
153+
{isLoading ? (
161154
<LoaderCircle className="size-3 animate-spin" />
162155
) : null}
163156
{statusLabel}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { ChatStatus } from "ai";
2+
3+
export type ParallelResponseLifecycle = "queued" | "generating" | "complete";
4+
5+
interface ParallelResponseStatusMessage {
6+
metadata: {
7+
activeStreamId: string | null;
8+
};
9+
}
10+
11+
export function getParallelResponseLifecycle(
12+
message: ParallelResponseStatusMessage | null
13+
): ParallelResponseLifecycle {
14+
if (!message || message.metadata.activeStreamId?.startsWith("pending:")) {
15+
return "queued";
16+
}
17+
if (message.metadata.activeStreamId !== null) {
18+
return "generating";
19+
}
20+
return "complete";
21+
}
22+
23+
export function getStatusLabel(
24+
isSelected: boolean,
25+
lifecycle: ParallelResponseLifecycle
26+
): string {
27+
if (lifecycle !== "complete") {
28+
return "Generating...";
29+
}
30+
return isSelected ? "Selected" : "Task completed";
31+
}
32+
33+
export function getResponseAwareStatus(
34+
status: ChatStatus,
35+
message: ParallelResponseStatusMessage | null
36+
): ChatStatus {
37+
const activeStreamId = message?.metadata.activeStreamId;
38+
if (!activeStreamId || status === "submitted" || status === "streaming") {
39+
return status;
40+
}
41+
42+
return activeStreamId.startsWith("pending:") ? "submitted" : "streaming";
43+
}

apps/chat/components/partial-message-loading.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { Skeleton } from "./ui/skeleton";
77
export function PartialMessageLoading({ messageId }: { messageId: string }) {
88
const metadata = useMessageMetadataById(messageId);
99
const status = useChatStatus();
10-
const isLoading = metadata.activeStreamId && status === "submitted";
10+
const isLoading =
11+
metadata.activeStreamId?.startsWith("pending:") ||
12+
(metadata.activeStreamId && status === "submitted");
1113

1214
if (!isLoading) {
1315
return null;

apps/chat/lib/start-provisional-chat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export function useStartProvisionalChat(chatId: string) {
7979
return false;
8080
}
8181

82+
storeState.setStatus("submitted");
83+
8284
registerProvisionalChatConfirmation(chatId, {
8385
message,
8486
projectId: currentRoute.projectId,

apps/chat/lib/stores/hooks-base.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,6 @@ export const useMessageMetadataById = (
9595

9696
export const useLastMessageId = () =>
9797
useBaseChatStore((state) => state.getLastMessageId());
98+
99+
export const useLastMessageMetadata = () =>
100+
useBaseChatStore((state) => state.getThrottledMessages().at(-1)?.metadata);

0 commit comments

Comments
 (0)