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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import type {
AgentChatApprovalDecision,
Expand Down Expand Up @@ -66,6 +66,7 @@ import {
deriveTurnModelState,
findAnchoredChatEventIndex,
formatElapsedSeconds,
ChatInfoHostContext,
getTranscriptCollapseCacheKeysForTests,
reconcileMeasuredScrollTop,
resetTranscriptCollapseCacheForTests,
Expand Down Expand Up @@ -119,6 +120,7 @@ function renderMessageList(
assistantLabel?: string;
initialState?: Record<string, unknown>;
showStreamingIndicator?: boolean;
sessionEnded?: boolean;
sessionId?: string | null;
transcriptCollapseCacheKey?: string | null;
laneId?: string | null;
Expand Down Expand Up @@ -146,6 +148,7 @@ function renderMessageList(
events={events}
assistantLabel={options?.assistantLabel}
showStreamingIndicator={options?.showStreamingIndicator}
sessionEnded={options?.sessionEnded}
sessionId={options?.sessionId}
transcriptCollapseCacheKey={options?.transcriptCollapseCacheKey}
laneId={options?.laneId}
Expand Down Expand Up @@ -3171,6 +3174,155 @@ describe("AgentChatMessageList transcript rendering", () => {
expect(transcriptOnly.container.textContent).not.toContain("Running command");
});

it("keeps the elapsed timer ticking when the first tool call wraps the status line in a button", () => {
// The status line renders bare while a turn has no tool activity and moves
// inside an expander <button> the moment the first tool entry lands. That
// swap remounts the timer <span>, so a timer that captured the element once
// would keep writing into the detached node and freeze on screen at "0s"
// while "taking longer than usual" still appears — the reported bug.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-17T10:00:00.000Z"));
try {
const before: AgentChatEventEnvelope[] = [
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:00.000Z",
sequence: 1,
event: { type: "user_message", text: "go", turnId: "turn-1" },
},
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:00.000Z",
sequence: 2,
event: { type: "text", text: "Let me check that.", itemId: "text-1", turnId: "turn-1" },
},
];
const after: AgentChatEventEnvelope[] = [
...before,
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:05.000Z",
sequence: 3,
event: {
type: "command",
command: "npm test",
cwd: "/repo",
output: "",
itemId: "cmd-1",
turnId: "turn-1",
status: "completed",
exitCode: 0,
},
},
];

const view = renderMessageList(before, { showStreamingIndicator: true });
act(() => { vi.advanceTimersByTime(5_000); });
expect(view.container.textContent).toContain("working for 5s");

view.rerender(
<MemoryRouter initialEntries={[{ pathname: "/" }]}>
<AgentChatMessageList events={after} showStreamingIndicator />
<LocationProbe />
</MemoryRouter>,
);
// The expander button is now present, so the status line remounted.
expect(screen.getByRole("button", { name: /activity from the active turn/i })).toBeTruthy();

act(() => { vi.advanceTimersByTime(5_000); });
expect(view.container.textContent).toContain("working for 10s");
} finally {
vi.useRealTimers();
}
});

it("renders a running background job as one line, with no dead open affordance", () => {
const runningJob: AgentChatEventEnvelope[] = [
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:00.000Z",
event: {
type: "scheduled_work_update",
id: "background:bg-1",
kind: "background_task",
status: "running",
title: "cd /repo && npm install",
sourceTaskId: "bg-1",
},
},
];

// No host is listening for `ade:chat:open-info` (PersonalChatsPage is one),
// so the affordance must not render at all — a button that silently does
// nothing is worse than an absent one.
const withoutHost = renderMessageList(runningJob);
const line = withoutHost.container.querySelector("[data-background-job]")!;
expect(line).toBeTruthy();
expect(line.getAttribute("data-background-job-status")).toBe("running");
expect(line.textContent).toContain("npm install");
expect(withoutHost.container.querySelector("[data-background-job] button")).toBeNull();
// Windows parity: bare ⚙/✓/✗ codepoints resolve to Segoe UI Emoji there,
// rendering as heavier colour glyphs off the baseline of the rule line.
// Status is carried by a Phosphor <svg>, never a text codepoint.
expect(line.textContent).not.toMatch(/[⚙✓✗]/);
expect(line.querySelector("svg")).toBeTruthy();
cleanup();

// Inside a host that owns the actions pane, the affordance appears and works.
const withHost = render(
<MemoryRouter initialEntries={[{ pathname: "/" }]}>
<ChatInfoHostContext.Provider value={true}>
<AgentChatMessageList events={runningJob} />
</ChatInfoHostContext.Provider>
</MemoryRouter>,
);
const openButton = withHost.container.querySelector("[data-background-job] button")!;
expect(openButton).toBeTruthy();

const openInfo = vi.fn();
window.addEventListener("ade:chat:open-info", openInfo);
try {
fireEvent.click(openButton);
expect(openInfo).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener("ade:chat:open-info", openInfo);
}
});

it("does not tick a background job that never finished in an ended session", () => {
// An archived chat whose job never got a terminal update stays `running`
// forever. Reporting "1440h" is arithmetically right and useless; the row
// shows no duration at all rather than asserting a number nobody should act
// on.
const endedJob: AgentChatEventEnvelope[] = [
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:00.000Z",
event: {
type: "scheduled_work_update",
id: "background:bg-1",
kind: "background_task",
status: "running",
title: "cd /repo && npm run dev",
sourceTaskId: "bg-1",
},
},
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:01.000Z",
event: { type: "done", turnId: "turn-1", status: "completed" },
},
];

const rendered = renderMessageList(endedJob, { sessionEnded: true });
const line = rendered.container.querySelector("[data-background-job]")!;
expect(line).toBeTruthy();
expect(line.getAttribute("data-background-job-status")).toBe("running");
expect(line.textContent).toContain("npm run dev");
// No elapsed at all — not a frozen one, and not a ticking one.
expect(line.textContent).not.toMatch(/\d+\s*(s|m|h|d)\b/);
});

it("keeps narration and file changes inline while completed tool activity moves behind the status line", () => {
const rendered = renderMessageList([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ import {
shouldCollapseUserMessageText,
summarizeDiffStats,
summarizeInlineText,
type BackgroundFinishChipRenderEvent,
type BackgroundJobLineRenderEvent,
type ChatActivityBundleEvent,
type ChatActivityBundleItem,
type CollapseTranscriptResult,
Expand All @@ -109,7 +109,7 @@ import {
type ChatTranscriptRenderEnvelope as TranscriptRenderEnvelope,
type ChatWorkLogEntry,
} from "./chatTranscriptRows";
import { BackgroundFinishChip, SubagentResultCard, SubagentSpawnCard, SubagentStoppedGroupCard } from "./SubagentActivityCards";
import { BackgroundJobLine, SubagentResultCard, SubagentSpawnCard, SubagentStoppedGroupCard } from "./SubagentActivityCards";
import { AdeCard } from "./AdeCard";
import { navigateToSpawnedChat } from "./spawnNavigation";
import { ChatUserMinimap } from "./ChatUserMinimap";
Expand Down Expand Up @@ -957,7 +957,7 @@ type RenderEnvelope = {
| SubagentSpawnAnchorRenderEvent
| SubagentResultCardRenderEvent
| SubagentStoppedGroupEvent
| BackgroundFinishChipRenderEvent
| BackgroundJobLineRenderEvent
| ScheduledWakeDividerRenderEvent
| SpawnWakeDividerRenderEvent;
};
Expand Down Expand Up @@ -1408,6 +1408,23 @@ function openChatInfoFromActivity(sessionId: string | null | undefined, taskId:
}
}

/**
* True inside a host that owns a chat actions pane and listens for
* `ade:chat:open-info` — i.e. `AgentChatPane`, which provides it.
* `PersonalChatsPage` mounts the same transcript with no actions pane and
* therefore leaves it false, so an affordance that opens that pane never
* renders as a button that silently does nothing.
*
* Deliberately a context and NOT a module-level "is any host alive" registry:
* `App` renders every `ProjectSurface` and only toggles `active`, so each
* `AgentChatPane` stays MOUNTED while Personal Chats is open. A global count
* would read true on exactly the surface that has no pane, and clicking would
* dispatch to a hidden pane that drops the event on the `sessionId` guard —
* recreating the dead affordance this is meant to prevent. Only the owning
* subtree can answer this question.
*/
export const ChatInfoHostContext = React.createContext(false);

function activityBundleDedupeKey(item: ChatActivityBundleItem): string {
const event = item.event;
if (event.type === "scheduled_work_update") {
Expand Down Expand Up @@ -1965,16 +1982,32 @@ function WorkingIndicator({
onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void;
sessionId?: string | null;
}) {
const timerRef = useRef<HTMLSpanElement>(null);
const timerRef = useRef<HTMLSpanElement | null>(null);
const startMsRef = useRef<number | null>(null);
const [longRunning, setLongRunning] = useState(false);
const [activityOpen, setActivityOpen] = useState(false);
const hasToolActivity = toolEntries.length > 0;
// The status line swaps between a bare <span> and an expander <button> the
// moment the turn's first tool entry lands, which makes React unmount and
// remount the timer element. Painting through a *callback* ref (rather than
// an element captured once when the ticker started) reattaches the counter to
// whichever node is currently mounted and repaints it in the same commit, so
// the swap can't strand the ticker on a detached node — the bug that froze
// the display at "0s" while "taking longer than usual" still appeared.
const attachTimer = useCallback((el: HTMLSpanElement | null) => {
timerRef.current = el;
if (!el) return;
const startMs = startMsRef.current ?? startedAt ?? Date.now();
el.textContent = formatElapsedSeconds((Date.now() - startMs) / 1000);
}, [startedAt]);
useEffect(() => {
const startMs = startedAt ?? Date.now();
const el = timerRef.current;
startMsRef.current = startMs;
let handle = 0;
const tick = () => {
const elapsedSec = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
// Re-read the ref every tick — see attachTimer above.
const el = timerRef.current;
if (el) el.textContent = formatElapsedSeconds(elapsedSec);
setLongRunning(elapsedSec >= LONG_RUNNING_TURN_SECONDS);
handle = window.setTimeout(tick, 1000);
Expand All @@ -1988,7 +2021,7 @@ function WorkingIndicator({
<span className="min-w-0 truncate font-medium text-fg/55">{activity ?? "Working"}</span>
<span className="shrink-0 text-fg/28" aria-hidden>·</span>
<span className="shrink-0 text-fg/38">
working for <span ref={timerRef} className="tabular-nums">0s</span>
working for <span ref={attachTimer} className="tabular-nums">0s</span>
</span>
{longRunning ? (
<>
Expand Down Expand Up @@ -2667,6 +2700,8 @@ function renderEvent(
assistantTurnCopy?: { text: string } | null;
/** Interrupt-receipt identities whose queued messages already ran → collapse. */
staleInterruptReceipts?: Set<string>;
/** True when a host is listening for `ade:chat:open-info` (see the registry). */
chatInfoHostAvailable?: boolean;
/** Cancel an ADE-owned queued message by uuid (stop-receipt affordance). */
onCancelQueuedMessage?: (uuid: string) => void;
onRestoreCancelledQueue?: (recoveryId: string) => Promise<boolean>;
Expand Down Expand Up @@ -3106,9 +3141,22 @@ function renderEvent(
);
}

/* ── Background command finish chip ── */
if (event.type === "background_finish_chip") {
return <BackgroundFinishChip event={event} />;
/* ── Background command one-liner (live from spawn through finish) ── */
if (event.type === "background_job_line") {
return (
<BackgroundJobLine
event={event}
sessionEnded={options?.sessionEnded}
Comment on lines +3147 to +3149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Plumb ended-session state into personal chat transcripts

When this new BackgroundJobLine is rendered from PersonalChatsPage, that host never passes sessionEnded to AgentChatMessageList (it renders the list at PersonalChatsPage.tsx around the selected events, while only deriving turnActive from selectedSession?.status). For an ended personal chat that still has a persisted background_task row stuck in running (the orphan/restart case this component explicitly handles), options?.sessionEnded is therefore false and the line keeps a live interval/ticker instead of freezing and dropping the bogus duration. Please pass selectedSession?.status === "ended" from the personal chat host as well.

Useful? React with 👍 / 👎.

// Same channel the sibling subagent card uses two branches up: the pane
// already listens for `ade:chat:open-info` and opens the agents tab,
// where background jobs live. A null taskId opens the tab without
// selecting an agent. Omitted entirely on a host with no actions pane,
// so the affordance never renders as a button that does nothing.
onOpenBackgroundJobs={options?.chatInfoHostAvailable
? () => openChatInfoFromActivity(options?.sessionId, null)
: undefined}
/>
);
}

/* ── Structured Question ── */
Expand Down Expand Up @@ -4613,6 +4661,7 @@ const EventRow = React.memo(function EventRow({
const workLogAnimate = Boolean(turnActive)
&& !sessionEnded
&& Boolean(isLatestWorkLog);
const chatInfoHostAvailable = React.useContext(ChatInfoHostContext);
return (
<div
data-chat-anchored-row={anchored ? "true" : undefined}
Expand Down Expand Up @@ -4689,6 +4738,7 @@ const EventRow = React.memo(function EventRow({
sessionId,
runtimeName,
onRevealChatTerminal,
chatInfoHostAvailable,
onRewindFiles,
turnDiffSummaries,
mosaic,
Expand Down
Loading
Loading