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
300 changes: 288 additions & 12 deletions apps/web/src/components/ChatView.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1166,7 +1166,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isEnvironmentUnavailable: boolean;
hasSendableContent: boolean;
preserveComposerFocusOnPointerDown?: boolean;
showSendWhileRunning?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
Expand Down Expand Up @@ -1200,7 +1199,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
isPreparingWorktree={props.isPreparingWorktree}
hasSendableContent={props.hasSendableContent}
preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
showSendWhileRunning={props.showSendWhileRunning ?? false}
onPreviousPendingQuestion={props.onPreviousPendingQuestion}
onInterrupt={props.onInterrupt}
onImplementPlanInNewThread={props.onImplementPlanInNewThread}
Expand Down Expand Up @@ -6845,7 +6843,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isPreparingWorktree={isPreparingWorktree}
hasSendableContent={composerSendState.hasSendableContent}
preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
showSendWhileRunning={isMobileViewport}
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
Expand Down
20 changes: 6 additions & 14 deletions apps/web/src/components/chat/ComposerPrimaryActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function renderPendingActions(isRunning: boolean) {
);
}

function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) {
function renderRunningActions(hasSendableContent: boolean) {
return renderToStaticMarkup(
createElement(ComposerPrimaryActions, {
compact: true,
Expand All @@ -58,7 +58,6 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent:
isEnvironmentUnavailable: false,
isPreparingWorktree: false,
hasSendableContent,
showSendWhileRunning,
onPreviousPendingQuestion: () => {},
onInterrupt: () => {},
onImplementPlanInNewThread: () => {},
Expand Down Expand Up @@ -125,25 +124,18 @@ describe("ComposerPrimaryActions", () => {
expect(markup).not.toContain("stage-nightly");
});

it("only renders stop while running when Enter-to-send is available", () => {
const markup = renderRunningActions(false, true);
it("renders a queue action alongside stop while running with a sendable draft", () => {
const markup = renderRunningActions(true);

expect(markup).toContain('aria-label="Stop generation"');
expect(markup).not.toContain('aria-label="Send message"');
});

it("renders send alongside stop while running when Enter-to-send is unavailable", () => {
const markup = renderRunningActions(true, true);

expect(markup).toContain('aria-label="Stop generation"');
expect(markup).toContain('aria-label="Send message"');
expect(markup).toContain('aria-label="Queue message"');
expect(markup).toContain('type="submit"');
});

it("keeps stop as the only action while running with an empty composer", () => {
const markup = renderRunningActions(true, false);
const markup = renderRunningActions(false);

expect(markup).toContain('aria-label="Stop generation"');
expect(markup).not.toContain('aria-label="Send message"');
expect(markup).not.toContain('aria-label="Queue message"');
});
});
14 changes: 7 additions & 7 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@ interface ComposerPrimaryActionsProps {
isPreparingWorktree: boolean;
hasSendableContent: boolean;
preserveComposerFocusOnPointerDown?: boolean;
/** Enter-to-send is disabled on mobile viewports, where stop would otherwise
* be the only primary action and a running turn could not be steered. */
showSendWhileRunning?: boolean;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
Expand Down Expand Up @@ -72,7 +69,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
isPreparingWorktree,
hasSendableContent,
preserveComposerFocusOnPointerDown = false,
showSendWhileRunning = false,
onPreviousPendingQuestion,
onInterrupt,
onImplementPlanInNewThread,
Expand All @@ -93,7 +89,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
"flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none",
insidePendingAction
? "size-8 sm:size-7"
: showSendWhileRunning && hasSendableContent
: hasSendableContent
? "size-9 sm:size-8"
: "size-8 sm:h-8 sm:w-8",
)}
Expand Down Expand Up @@ -247,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
? "Preparing worktree"
: isSendBusy
? "Sending"
: "Send message"
: isRunning
? "Queue message"
: "Send message"
}
>
{stageBackdropVariant ? (
Expand Down Expand Up @@ -275,10 +273,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
return sendButton;
}

// While a turn runs, a sendable draft queues for the next tool boundary, so
// the send button stays next to Stop on every viewport.
return (
<>
{renderStopGenerationButton(false)}
{showSendWhileRunning && hasSendableContent ? sendButton : null}
{hasSendableContent ? sendButton : null}
</>
);
});
34 changes: 34 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,40 @@ describe("resolveAssistantMessageCopyState", () => {
});

describe("deriveMessagesTimelineRows", () => {
it("appends queued messages after the live rows, marking the oldest as next", () => {
const queuedMessage = (id: string, prompt: string) => ({
id,
prompt,
images: [],
files: [],
terminalContexts: [],
previewAnnotations: [],
reviewComments: [],
submissionIntent: "foreground" as const,
queuedAfterToolActivityId: null,
createdAt: "2026-01-01T00:00:01Z",
});
const rows = deriveMessagesTimelineRows({
timelineEntries: [],
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaries: [],
supportsConversationRollback: false,
queuedMessages: [queuedMessage("q1", "first"), queuedMessage("q2", "second")],
});

expect(rows.map((row) => row.kind)).toEqual([
"working",
"thinking",
"queued-message",
"queued-message",
]);
expect(rows.slice(2)).toMatchObject([
{ id: "queued-message:q1", isNext: true, queuedMessage: { prompt: "first" } },
{ id: "queued-message:q2", isNext: false, queuedMessage: { prompt: "second" } },
]);
});

it("shows the worktree setup card instead of the working placeholder", () => {
const snapshot: WorktreeSetupSnapshot = {
threadId: ThreadId.make("thread-setup"),
Expand Down
29 changes: 27 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type WorkLogEntry,
} from "../../session-logic";
import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
import type { QueuedComposerMessage } from "../../queuedMessageStore";
import {
type MessageId,
type OrchestrationLatestTurn,
Expand Down Expand Up @@ -402,6 +403,14 @@ export type MessagesTimelineRow =
snapshot: WorktreeSetupSnapshot;
/** The agent already started; render only the script row under the turn header. */
embedded: boolean;
}
| {
kind: "queued-message";
id: string;
createdAt: string;
queuedMessage: QueuedComposerMessage;
/** Oldest queued message, the one the next boundary sends. */
isNext: boolean;
};

export interface StableMessagesTimelineRowsState {
Expand Down Expand Up @@ -872,6 +881,8 @@ export function deriveMessagesTimelineRows(input: {
liveAgentTaskIds?: ReadonlySet<string> | undefined;
/** Live bootstrap progress. Renders a stage card under the first user message. */
worktreeSetup?: WorktreeSetupSnapshot | null;
/** Messages sent during the running turn, rendered after the live rows. */
queuedMessages?: ReadonlyArray<QueuedComposerMessage>;
}): MessagesTimelineRow[] {
const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
for (const summary of input.turnDiffSummaries) {
Expand Down Expand Up @@ -1329,8 +1340,17 @@ export function deriveMessagesTimelineRows(input: {
createdAt: input.activeTurnStartedAt,
});
}

return attachTrailingToolGroupsToAssistant(nextRows);
const rows = attachTrailingToolGroupsToAssistant(nextRows);
input.queuedMessages?.forEach((queuedMessage, index) => {
rows.push({
kind: "queued-message",
id: `queued-message:${queuedMessage.id}`,
createdAt: queuedMessage.createdAt,
queuedMessage,
isNext: index === 0,
});
});
return rows;
}

export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
Expand Down Expand Up @@ -1468,6 +1488,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
case "proposed-plan":
return a.proposedPlan === (b as typeof a).proposedPlan;

case "queued-message": {
const bq = b as typeof a;
return a.queuedMessage === bq.queuedMessage && a.isNext === bq.isNext;
}

case "work": {
const bw = b as typeof a;
return (
Expand Down
Loading
Loading