diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 1108526dfb0..35599105a4b 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -39,7 +39,7 @@ Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect. ### `task-detail` -Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** loads earlier messages and run activity; incoming messages remain visible while the task stays open. +Open `/issues/:issueId` from a row, mention, or direct link. Cold opens show the instruction and recent exchange first. **More messages** appears only when earlier comments or runs are omitted and loads that history. Fresh tasks with no omitted history have no disclosure. A single legacy run with a truncated log offers **More run output** instead. **More task actions → Show task activity** loads full activity even for a single native run; incoming messages remain visible while the task stays open. ### `task-sharing` diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index f097f68ec7b..006d7966adf 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -26,6 +26,7 @@ const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map(), isInitialHydrating: false, hydratedRunIds: undefined as Set | undefined, + truncatedRunIds: new Set(), })); const nativeTranscriptState = vi.hoisted(() => ({ transcriptByRun: new Map(), @@ -35,6 +36,7 @@ const nativeTranscriptState = vi.hoisted(() => ({ })); const transcriptHookRuns = vi.hoisted(() => ({ legacy: [] as unknown[][], + legacyReadLimits: [] as (number | undefined)[], native: [] as unknown[][], })); const sidebarState = vi.hoisted(() => ({ isMobile: false })); @@ -51,12 +53,14 @@ const editorCallbacks = vi.hoisted(() => ({ change: (_value: string) => {} })); const streamlinedState = vi.hoisted(() => ({ enabled: true })); vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({ - useLiveRunTranscripts: ({ runs }: { runs: unknown[] }) => { + useLiveRunTranscripts: ({ runs, logReadLimitBytes }: { runs: unknown[]; logReadLimitBytes?: number }) => { transcriptHookRuns.legacy.push(runs); + transcriptHookRuns.legacyReadLimits.push(logReadLimitBytes); return { transcriptByRun: new Map(transcriptState.transcriptByRun), isInitialHydrating: transcriptState.isInitialHydrating, hydratedRunIds: transcriptState.hydratedRunIds, + truncatedRunIds: transcriptState.truncatedRunIds, }; }, })); @@ -122,11 +126,13 @@ beforeEach(() => { transcriptState.transcriptByRun.clear(); transcriptState.isInitialHydrating = false; transcriptState.hydratedRunIds = undefined; + transcriptState.truncatedRunIds.clear(); nativeTranscriptState.transcriptByRun.clear(); nativeTranscriptState.errorsByRun.clear(); nativeTranscriptState.hydratedRunIds = undefined; nativeTranscriptState.terminalHydratedRunIds = undefined; transcriptHookRuns.legacy.length = 0; + transcriptHookRuns.legacyReadLimits.length = 0; transcriptHookRuns.native.length = 0; sidebarState.isMobile = false; planState.data = null; @@ -371,6 +377,27 @@ it.each([true, false])("keeps cold-open messages readable while history is pendi expect(container.querySelector('[data-testid="mock-editor"]')).not.toBeNull(); }); +it.each([0, 32_000, 100_000])("offers earlier output only when a single legacy run exceeds the compact window (%s bytes)", (logBytes) => { + const onExpandHistory = vi.fn(); + if (logBytes > 32_000) transcriptState.truncatedRunIds.add("legacy-run"); + const props = { + issueId: "single-legacy-run", comments: [], onAdd: async () => {}, onExpandHistory, + linkedRuns: [{ runId: "legacy-run", agentId: "agent", status: "succeeded", adapterType: "claude_local", runtimeMode: "legacy" as const, createdAt: "2025-01-01T10:00:00Z", startedAt: "2025-01-01T10:00:00Z", logBytes }], + }; + render(); + expect(container.textContent).not.toContain("More messages"); + const outputControl = [...container.querySelectorAll("button")].find((button) => button.textContent === "More run output"); + expect(Boolean(outputControl)).toBe(logBytes > 32_000); + expect(transcriptHookRuns.legacyReadLimits.at(-1)).toBe(32_000); + if (outputControl) { + act(() => outputControl.click()); + expect(onExpandHistory).toHaveBeenCalledOnce(); + render(); + expect(container.textContent).not.toContain("More run output"); + expect(transcriptHookRuns.legacyReadLimits.at(-1)).toBeUndefined(); + } +}); + it("defers initial history for a saved conversation with its own draft key", () => { render( {}} issueId="saved-chat" conversationMode draftKey="agent-chat:company:saved-agent" initialHistoryPending />); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 0b96d18bf1b..7848fedce7f 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -138,6 +138,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { TaskChatPresentationProvider } from "@/components/task-chat/presentation-mode"; const EMPTY_BROWSERS: TaskBrowser[] = []; +const COMPACT_RUN_LOG_READ_LIMIT_BYTES = 32_000; function toMs(value: Date | string | null | undefined): number { if (!value) return 0; @@ -424,6 +425,7 @@ export type TaskChatThreadProps = ComponentProps & { creationActivity?: ActivityEvent[]; compactHistory?: boolean; historyControl?: ReactNode; + onExpandHistory?: () => void; initialHistoryPending?: boolean; initialHistoryError?: boolean; onRetryInitialHistory?: () => void; @@ -966,9 +968,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { transcriptByRun: logTranscriptByRun, isInitialHydrating: logsAreInitiallyHydrating, hydratedRunIds: hydratedLogRunIds, + truncatedRunIds, errorsByRun: logErrorsByRun, retry: retryLogs, - } = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: 32_000 } : {}) }); + } = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: COMPACT_RUN_LOG_READ_LIMIT_BYTES } : {}) }); const fallbackByRunRef = useRef( new Map>>(), ); @@ -2824,9 +2827,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { ], ); + const historyControl = props.historyControl ?? ( + props.compactHistory && props.onExpandHistory + && logRuns.some((run) => truncatedRunIds?.has(run.id)) + ? + : null + ); const renderBrief = useCallback( - () => issueBrief ? <>{props.historyControl} : null, - [issueBrief, props.historyControl], + () => issueBrief ? <>{historyControl} : null, + [issueBrief, historyControl], ); const assignedAgentForNotice = useMemo(() => { @@ -3127,7 +3136,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { {threadHeaderWithBlockers}{!issueBrief ? props.historyControl : null}} + header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}} renderInteraction={renderInteraction} renderBrief={renderBrief} renderMessageActions={renderMessageActions} diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx index df3419993d1..0636b1f6267 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx @@ -131,17 +131,18 @@ describe("useLiveRunTranscripts", () => { }); it("expands a compact log tail without hiding or duplicating the latest output", async () => { + let truncatedRunIds: ReadonlySet = new Set(); const row = (seq: number, chunk: string) => JSON.stringify({ seq, chunk, ts: `2026-10-24T00:00:0${seq}Z`, stream: "stdout" }); const result = (content: string) => ({ runId: "run-1", store: "memory", logRef: "log-1", content, nextOffset: 100_000 }); let resolveExpanded!: (value: ReturnType) => void; logMock.mockResolvedValueOnce(result(`${row(2, "Latest answer")}\n`)); logMock.mockImplementationOnce(() => new Promise(resolve => { resolveExpanded = resolve; })); function Harness({ limit, bytes = 100_000 }: { limit: number; bytes?: number }) { - useLiveRunTranscripts({ + ({ truncatedRunIds } = useLiveRunTranscripts({ runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", logBytes: bytes }], enableRealtimeUpdates: false, logReadLimitBytes: limit, - }); + })); return null; } const root = createRoot(document.createElement("div")); @@ -149,11 +150,13 @@ describe("useLiveRunTranscripts", () => { try { await act(async () => root.render()); expect(logMock).toHaveBeenLastCalledWith("run-1", 68_000, 32_000, expect.objectContaining({ alignStartToRecord: true })); + expect(truncatedRunIds.has("run-1")).toBe(true); expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]); await act(async () => root.render()); expect(logMock).toHaveBeenLastCalledWith("run-1", 0, 256_000, expect.anything()); expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]); await act(async () => resolveExpanded(result(`${row(1, "Earlier activity")}\n${row(2, "Latest answer")}\n`))); + expect(truncatedRunIds.has("run-1")).toBe(false); expect(chunks()).toEqual([ expect.objectContaining({ chunk: "Earlier activity" }), expect.objectContaining({ chunk: "Latest answer" }), @@ -165,6 +168,30 @@ describe("useLiveRunTranscripts", () => { } }); + it("does not report omitted output when a run grows beyond the window after reading from the start", async () => { + let truncatedRunIds: ReadonlySet = new Set(); + const row = (seq: number) => `${JSON.stringify({ seq, chunk: `Output ${seq}`, stream: "stdout", ts: "2026-10-24T00:00:00Z" })}\n`; + const response = { runId: "run-1", store: "memory", logRef: "log-1" }; + logMock.mockResolvedValueOnce({ ...response, content: row(1), nextOffset: 100 }); + logMock.mockResolvedValueOnce({ ...response, content: row(2), nextOffset: 100_000 }); + function Harness({ bytes }: { bytes: number }) { + ({ truncatedRunIds } = useLiveRunTranscripts({ + runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", logBytes: bytes }], + enableRealtimeUpdates: false, logReadLimitBytes: 32_000, + })); + return null; + } + const root = createRoot(document.createElement("div")); + try { + await act(async () => root.render()); + expect(logMock).toHaveBeenLastCalledWith("run-1", 0, 32_000, expect.anything()); + expect(truncatedRunIds.has("run-1")).toBe(false); + await act(async () => root.render()); + expect(logMock).toHaveBeenLastCalledWith("run-1", 100, 32_000, expect.anything()); + expect(truncatedRunIds.has("run-1")).toBe(false); + } finally { await act(async () => root.unmount()); } + }); + it("keeps record alignment pending when an expanded log read needs retry", async () => { const row = (seq: number, chunk: string) => JSON.stringify({ seq, chunk, ts: `2026-10-24T00:00:0${seq}Z`, stream: "stdout" }); const result = (content: string) => ({ runId: "run-1", store: "memory", logRef: "log-1", content, nextOffset: 500_000 }); diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index 978f0766479..134e88e766d 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -144,6 +144,7 @@ export function useLiveRunTranscripts({ const pendingLogRowsByRunRef = useRef(new Map()); const logOffsetByRunRef = useRef(new Map()); const logReadLimitByRunRef = useRef(new Map()); + const [truncatedRunIds, setTruncatedRunIds] = useState>(new Set()); const missingTerminalLogRunIdsRef = useRef(new Set()); // PAP-462 B3: buffered runs that dropped out of the `runs` list, mapped to the // wall-clock deadline (ms) after which their buffer may be pruned. A run still @@ -260,6 +261,10 @@ export function useLiveRunTranscripts({ const next = new Map([...previous].filter(([id]) => retainedRunIds.has(id))); return next.size === previous.size ? previous : next; }); + setTruncatedRunIds((previous) => { + const next = new Set([...previous].filter((id) => retainedRunIds.has(id))); + return next.size === previous.size ? previous : next; + }); for (const key of pendingLogRowsByRunRef.current.keys()) { const runId = key.replace(/:records$/, ""); @@ -340,6 +345,15 @@ export function useLiveRunTranscripts({ // Commit the expanded window only after success so retries still align // its first record and failed reads leave the previous parser intact. + if (previousReadLimit === undefined || rewinding) { + setTruncatedRunIds((previous) => { + if (previous.has(run.id) === (offset > 0)) return previous; + const next = new Set(previous); + if (offset > 0) next.add(run.id); + else next.delete(run.id); + return next; + }); + } logReadLimitByRunRef.current.set(run.id, logReadLimitBytes); if (rewinding) { pendingLogRowsByRunRef.current.delete(run.id); @@ -596,7 +610,7 @@ export function useLiveRunTranscripts({ return { transcriptByRun, - hydratedRunIds, errorsByRun, retry, + hydratedRunIds, truncatedRunIds, errorsByRun, retry, isInitialHydrating: normalizedRuns.some((run) => canReadPersistedLog(run) && !hydratedRunIds.has(run.id)), hasOutputForRun(runId: string) { return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true; diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 8543d8961aa..3d3726cc97f 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1684,6 +1684,100 @@ describe("IssueDetail", () => { expect(windowOpen).not.toHaveBeenCalled(); }); + it.each([null, "queued", "running", "succeeded", "failed"])("hides the history disclosure on a fresh task with first run %s", async (status) => { + queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); + mockIssuesApi.get.mockResolvedValue(createIssue()); + const runs = status ? [{ runId: "first-run", status, agentId: "agent-1", createdAt: new Date().toISOString() }] : []; + mockActivityApi.recentRunsForIssue.mockResolvedValue(runs); + await act(async () => renderIssueView()); + await waitForAssertion(() => { + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0]; + expect(mockActivityApi.recentRunsForIssue).toHaveBeenCalled(); + expect(props?.linkedRuns).toMatchObject(runs); + expect(props?.compactHistory).toBe(true); + expect(props?.historyControl).toBeNull(); + }); + expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled(); + expect(mockIssuesApi.listComments).not.toHaveBeenCalled(); + }); + + it("loads full first-run activity from the task menu without an older-message disclosure", async () => { + queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); + mockIssuesApi.get.mockResolvedValue(createIssue()); + const run = { runId: "first-run", runtimeMode: "native", status: "succeeded", agentId: "agent-1", createdAt: new Date().toISOString() }; + mockActivityApi.recentRunsForIssue.mockResolvedValue([run]); + mockActivityApi.runsForIssue.mockResolvedValue([run]); + await act(async () => renderIssueView()); + await waitForAssertion(() => { + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0]; + expect(props?.linkedRuns).toMatchObject([run]); + expect(props?.compactHistory).toBe(true); + expect(props?.historyControl).toBeNull(); + }); + expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled(); + await act(async () => { + container.querySelector('button[aria-label="More task actions"]')!.click(); + }); + const activityButton = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Show task activity"); + expect(activityButton).toBeTruthy(); + await act(async () => activityButton!.click()); + await waitForAssertion(() => { + expect(mockActivityApi.runsForIssue).toHaveBeenCalled(); + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].compactHistory).toBe(false); + }); + await act(async () => { + container.querySelector('button[aria-label="More task actions"]')!.click(); + }); + expect(Array.from(document.body.querySelectorAll("button")) + .some((button) => button.textContent?.trim() === "Show task activity")).toBe(false); + }); + + it("does not add a history disclosure when a second run is observed while the page stays open", async () => { + queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); + mockIssuesApi.get.mockResolvedValue(createIssue()); + const run = (runId: string) => ({ runId, status: "succeeded", agentId: "agent-1", createdAt: new Date().toISOString() }); + const first = run("first"), second = run("second"); + mockActivityApi.recentRunsForIssue.mockResolvedValue([first]); + await act(async () => renderIssueView()); + await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns).toMatchObject([first])); + mockActivityApi.recentRunsForIssue.mockResolvedValue([second, first]); + await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs("PAP-1") }); }); + await waitForAssertion(() => { + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0]; + expect(props?.linkedRuns).toMatchObject([first, second]); + expect(props?.historyControl).toBeNull(); + }); + expect(mockActivityApi.runsForIssue).not.toHaveBeenCalled(); + }); + + it("reveals omitted runs through the history disclosure when there are no older comments", async () => { + queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); + mockIssuesApi.get.mockResolvedValue(createIssue()); + const run = (runId: string) => ({ runId, status: "succeeded", agentId: "agent-1", createdAt: new Date().toISOString() }); + const latest = run("latest"), older = run("older"), next = run("next"); + mockActivityApi.recentRunsForIssue.mockResolvedValue([latest, older]); + mockActivityApi.runsForIssue.mockResolvedValue([next, latest, older]); + await act(async () => renderIssueView()); + await waitForAssertion(() => { + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0]; + expect(props?.linkedRuns).toMatchObject([latest]); + expect(props?.historyControl).toBeTruthy(); + }); + mockActivityApi.recentRunsForIssue.mockResolvedValue([next, latest]); + await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs("PAP-1") }); }); + await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].linkedRuns).toMatchObject([latest, next])); + const disclosure = mockIssueChatThreadRender.mock.calls.at(-1)?.[0].historyControl; + expect(disclosure).toBeTruthy(); + await act(async () => disclosure.props.onClick()); + await waitForAssertion(() => { + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0]; + expect(props?.linkedRuns).toMatchObject([next, latest, older]); + expect(props?.historyControl).toBeNull(); + expect(props?.compactHistory).toBe(false); + }); + }); + it("reveals a cold task and recent exchange while full detail and older history are stalled", async () => { queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false); const stalled = createDeferred(); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index f4b707bf6b8..33d0cf9d6cb 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1203,6 +1203,11 @@ function InboxMobileToolbar({ ); } +interface RecentIssueRunWindow { + runs: RunForIssue[]; + hasEarlierRuns: boolean; +} + type IssueDetailChatTabProps = { executionBlocked?: boolean; interruption?: Issue["interruption"]; @@ -1620,37 +1625,48 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun; const linkedRunsKey = historyExpanded ? queryKeys.issues.runs(issueQueryRef) - : [...queryKeys.issues.runs(issueQueryRef), "recent"]; + : [...queryKeys.issues.runs(issueQueryRef), "recent-window"]; const { - data: linkedRuns, + data: linkedRunHistory, isPending: linkedRunsPending, isError: linkedRunsError, refetch: refetchLinkedRuns, - } = useQuery({ + } = useQuery({ queryKey: linkedRunsKey, queryFn: async () => { if (historyExpanded) return activityApi.runsForIssue(issueQueryRef); let recent = await activityApi.recentRunsForIssue(issueQueryRef); - let previous = queryClient.getQueryData(linkedRunsKey) ?? []; + const previousWindow = queryClient.getQueryData(linkedRunsKey); + let previous = previousWindow?.runs ?? []; + let reconciledFullHistory = false; if (previous.some((run) => (run.status === "running" || run.status === "queued") && !recent.some((next) => next.runId === run.runId), )) { // After a reconnect, several turns may have completed beyond the small // window. Reconcile watched runs once against authoritative summaries. recent = await activityApi.runsForIssue(issueQueryRef); + reconciledFullHistory = true; const existingRunIds = new Set(recent.map((run) => run.runId)); previous = previous.filter((run) => existingRunIds.has(run.runId)); } // The second row refreshes a just-finished predecessor. Start with only // the latest run, then keep every run observed while this task is open. const visible = new Set([...previous.map((run) => run.runId), ...recent.slice(0, 1).map((run) => run.runId)]); - return [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()]; + return { + runs: [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()], + // A first run is already visible. The disclosure needs evidence of an + // omitted run, and must retain that evidence as later turns arrive. + hasEarlierRuns: recent.some((run) => !visible.has(run.runId)) + || (!reconciledFullHistory && recent.length >= 2 && previousWindow?.hasEarlierRuns === true), + }; }, enabled: !!issueId, refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 1000 : false, - placeholderData: historyExpanded ? queryClient.getQueryData([...queryKeys.issues.runs(issueQueryRef), "recent"]) : undefined, + placeholderData: historyExpanded ? queryClient.getQueryData([...queryKeys.issues.runs(issueQueryRef), "recent-window"])?.runs : undefined, }); + const linkedRuns = Array.isArray(linkedRunHistory) ? linkedRunHistory : linkedRunHistory?.runs; + const hasEarlierRuns = !Array.isArray(linkedRunHistory) && linkedRunHistory?.hasEarlierRuns === true; const resolvedActivity = useMemo( () => [...new Map([...(activity ?? []), ...(inputFacts.data ?? [])].map((event) => [event.id, event])).values()], [activity, inputFacts.data], @@ -2560,7 +2576,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ 0) ? ( + onExpandHistory, + historyControl: !historyExpanded && (hasEarlierMessages || hasEarlierRuns) ? ( @@ -7661,6 +7678,18 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS + {taskChatShellEnabled && !historyExpanded ? ( + + ) : null} {streamlinedTaskDetailEnabled ? ( <>