Skip to content
Open
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
40 changes: 40 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
} from "../lib/terminalContext";
import { isMacPlatform } from "../lib/utils";
import { __resetLocalApiForTests } from "../localApi";
import { useLocalDispatchStore } from "../localDispatchStore";
import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
import { getServerConfig } from "../rpc/serverState";
import { getRouter } from "../router";
Expand Down Expand Up @@ -675,6 +676,34 @@ async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId):
sendShellThreadUpsert(threadId, { session: null });
}

async function materializePromotedDraftThreadWithUserMessageViaDomainEvent(
threadId: ThreadId,
): Promise<void> {
await waitForWsClient();
fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);
fixture.snapshot = {
...fixture.snapshot,
snapshotSequence: fixture.snapshot.snapshotSequence + 1,
threads: fixture.snapshot.threads.map((thread) =>
thread.id === threadId
? {
...thread,
session: null,
messages: [
createUserMessage({
id: "msg-promoted-draft-pending-start" as MessageId,
text: "Ship it",
offsetSeconds: 1,
}),
],
updatedAt: NOW_ISO,
}
: thread,
),
};
sendShellThreadUpsert(threadId, { session: null });
}

async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, {
threadId,
Expand Down Expand Up @@ -1814,6 +1843,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
});
useRightPanelStore.persist.clearStorage();
useRightPanelStore.setState({ byThreadKey: {} });
useLocalDispatchStore.setState({ byThreadKey: {} });
});

afterEach(() => {
Expand Down Expand Up @@ -2789,6 +2819,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
expect(sendButton.disabled).toBe(false);
sendButton.click();

await expect.element(page.getByText("Starting request for", { exact: false })).toBeVisible();

await vi.waitFor(
() => {
const dispatchRequest = wsRequests.find(
Expand Down Expand Up @@ -2823,6 +2855,14 @@ describe("ChatView timeline estimator parity (full app)", () => {
{ timeout: 8_000, interval: 16 },
);

await materializePromotedDraftThreadWithUserMessageViaDomainEvent(THREAD_ID);
await waitForURL(
mounted.router,
(path) => path === serverThreadPath(THREAD_ID),
"Promoted draft should canonicalize after the user message is persisted.",
);
await expect.element(page.getByText("Starting request for", { exact: false })).toBeVisible();

expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe(
false,
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(true);
});

it("clears local dispatch when the session changes without an observed running phase", () => {
it("keeps local dispatch when session metadata changes before the new turn starts", () => {
const localDispatch = createLocalDispatchSnapshot({
id: ThreadId.make("thread-1"),
environmentId: localEnvironmentId,
Expand Down Expand Up @@ -860,6 +860,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
hasPendingUserInput: false,
threadError: null,
}),
).toBe(true);
).toBe(false);
});
});
6 changes: 1 addition & 5 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,9 +442,5 @@ export function hasServerAcknowledgedLocalDispatch(input: {
return true;
}

return (
latestTurnChanged ||
input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) ||
input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
);
return latestTurnChanged && latestTurn !== null && latestTurn.startedAt !== null;
}
42 changes: 26 additions & 16 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import {
type TerminalContextSelection,
} from "../lib/terminalContext";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { selectLocalDispatchSnapshot, useLocalDispatchStore } from "../localDispatchStore";
import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState";
import {
appendElementContextsToPrompt,
Expand Down Expand Up @@ -183,7 +184,6 @@ import {
getStartedThreadModelChangeBlockReason,
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
LastInvokedScriptByProjectSchema,
type LocalDispatchSnapshot,
PullRequestDialogState,
cloneComposerImageForRetry,
deriveLockedProvider,
Expand Down Expand Up @@ -429,33 +429,35 @@ interface TerminalLaunchContext {
type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">;

function useLocalDispatchState(input: {
activeThreadRef: ScopedThreadRef | null;
activeThread: Thread | undefined;
activeLatestTurn: Thread["latestTurn"] | null;
phase: SessionPhase;
activePendingApproval: ApprovalRequestId | null;
activePendingUserInput: ApprovalRequestId | null;
threadError: string | null | undefined;
}) {
const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
const localDispatch = useLocalDispatchStore((state) =>
selectLocalDispatchSnapshot(state.byThreadKey, input.activeThreadRef),
);
const beginDispatch = useLocalDispatchStore((state) => state.begin);
const clearDispatch = useLocalDispatchStore((state) => state.clear);

const beginLocalDispatch = useCallback(
(options?: { preparingWorktree?: boolean }) => {
const preparingWorktree = Boolean(options?.preparingWorktree);
setLocalDispatch((current) => {
if (current) {
return current.preparingWorktree === preparingWorktree
? current
: { ...current, preparingWorktree };
}
return createLocalDispatchSnapshot(input.activeThread, options);
});
if (!input.activeThreadRef) return;
beginDispatch(
input.activeThreadRef,
createLocalDispatchSnapshot(input.activeThread, options),
);
},
[input.activeThread],
[beginDispatch, input.activeThread, input.activeThreadRef],
);

const resetLocalDispatch = useCallback(() => {
setLocalDispatch(null);
}, []);
if (!input.activeThreadRef) return;
clearDispatch(input.activeThreadRef);
}, [clearDispatch, input.activeThreadRef]);
Comment thread
gbarros-dev marked this conversation as resolved.

const serverAcknowledgedLocalDispatch = useMemo(
() =>
Expand Down Expand Up @@ -1846,6 +1848,7 @@ export default function ChatView(props: ChatViewProps) {
isPreparingWorktree,
isSendBusy,
} = useLocalDispatchState({
activeThreadRef,
activeThread,
activeLatestTurn,
phase,
Expand Down Expand Up @@ -3218,16 +3221,23 @@ export default function ChatView(props: ChatViewProps) {
};
}, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]);

const previousCleanupThreadIdRef = useRef<ThreadId | null>(null);
useEffect(() => {
const nextThreadId = activeThread?.id ?? null;
const previousThreadId = previousCleanupThreadIdRef.current;
previousCleanupThreadIdRef.current = nextThreadId;
if (previousThreadId === nextThreadId) {
return;
}

setOptimisticUserMessages((existing) => {
for (const message of existing) {
revokeUserMessagePreviewUrls(message);
}
return [];
});
resetLocalDispatch();
setExpandedImage(null);
}, [draftId, resetLocalDispatch, threadId]);
}, [activeThread?.id]);

const closeExpandedImage = useCallback(() => {
setExpandedImage(null);
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,48 @@ describe("MessagesTimeline", () => {
}
});

it("labels locally dispatched work as starting until the provider turn begins", async () => {
const props = buildProps();
const startedAt = new Date().toISOString();
const screen = await render(
<MessagesTimeline
{...props}
isWorking
activeTurnStartedAt={startedAt}
timelineEntries={[]}
latestTurn={{
turnId: "turn-previous" as never,
state: "completed",
startedAt: "2026-04-13T12:00:00.000Z",
completedAt: "2026-04-13T12:00:01.000Z",
}}
/>,
);

try {
await expect.element(page.getByText("Starting request for", { exact: false })).toBeVisible();

await screen.rerender(
<MessagesTimeline
{...props}
isWorking
activeTurnStartedAt={startedAt}
timelineEntries={[]}
latestTurn={{
turnId: "turn-next" as never,
state: "running",
startedAt,
completedAt: null,
}}
/>,
);

await expect.element(page.getByText("Working for", { exact: false })).toBeVisible();
} finally {
await screen.unmount();
}
});

it("starts long user messages collapsed by default", async () => {
const screen = await render(
<MessagesTimeline
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,12 @@ describe("deriveMessagesTimelineRows", () => {
]);
const finalRow = rows.find((row) => row.id === "assistant-final-entry");
expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
const workingRow = rows.find((row) => row.id === "working-indicator-row");
expect(workingRow).toMatchObject({
kind: "working",
createdAt: "2026-01-01T00:01:00Z",
phase: "starting",
});
});

it("does not fold the active in-progress turn", () => {
Expand Down Expand Up @@ -727,6 +733,12 @@ describe("deriveMessagesTimelineRows", () => {
"work-entry-1",
"working-indicator-row",
]);
const workingRow = rows.find((row) => row.id === "working-indicator-row");
expect(workingRow).toMatchObject({
kind: "working",
createdAt: "2026-01-01T00:00:00Z",
phase: "running",
});
});

it("only shows assistant metadata on the terminal assistant message", () => {
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ export type MessagesTimelineRow =
createdAt: string;
proposedPlan: ProposedPlan;
}
| { kind: "working"; id: string; createdAt: string | null };
| {
kind: "working";
id: string;
createdAt: string | null;
phase: "starting" | "running";
};

export interface StableMessagesTimelineRowsState {
byId: Map<string, MessagesTimelineRow>;
Expand Down Expand Up @@ -162,6 +167,15 @@ function deriveUnsettledTurnId(latestTurn: TimelineLatestTurn | null): TurnId |
return isSettled ? null : latestTurn.turnId;
}

function deriveWorkingPhase(
latestTurn: TimelineLatestTurn | null | undefined,
): "starting" | "running" {
if (latestTurn?.startedAt && latestTurn.completedAt === null && latestTurn.state === "running") {
return "running";
}
return "starting";
}

/**
* Settled turns fold their commentary and tool activity behind a
* "Worked for ..." row anchored at the turn's first foldable entry; the
Expand Down Expand Up @@ -418,6 +432,7 @@ export function deriveMessagesTimelineRows(input: {
kind: "working",
id: "working-indicator-row",
createdAt: input.activeTurnStartedAt,
phase: deriveWorkingPhase(input.latestTurn),
});
}

Expand Down Expand Up @@ -450,7 +465,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean

switch (a.kind) {
case "working":
return a.createdAt === (b as typeof a).createdAt;
return a.createdAt === (b as typeof a).createdAt && a.phase === (b as typeof a).phase;

case "turn-fold": {
const bf = b as typeof a;
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,8 @@ function ProposedPlanTimelineRow({
}

function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
const label = row.phase === "running" ? "Working" : "Starting request";

return (
<div className="py-0.5 pl-1.5">
<div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">
Expand All @@ -672,10 +674,10 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
<span>
{row.createdAt ? (
<>
Working for <WorkingTimer createdAt={row.createdAt} />
{label} for <WorkingTimer createdAt={row.createdAt} />
</>
) : (
"Working..."
`${label}...`
)}
</span>
</div>
Expand Down
Loading
Loading