From a67a18c171816b097f46eae78fc58c7e55c9c565 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:58:30 -0500 Subject: [PATCH] fix(tasks): hide empty message history disclosure (#17751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Each task page shows the instruction and saved conversation. > - Cold opens show recent content before older history. > - The page used any linked run as evidence of older messages. > - A task's first run therefore showed an empty More messages control. > - This PR shows the control only when the preview omits comments or runs. > - Fresh tasks stay clear, and older task history remains available. ## Linked Issues or Issue Description Refs: #17593. **What happened?** A fresh task with only its first run displayed More messages, even though there was no earlier history to load. **Expected behavior** Show More messages only when older comments or runs are omitted. Runs observed while the page stays open must not create a false disclosure. The output control also uses actual skipped bytes, so a growing run does not gain an empty output control. **Steps to reproduce** 1. Create a task and let its first run start or finish. 2. Open that task with a fresh application cache. 3. Observe the empty More messages control below its instruction. ## What Changed - Keep omitted-run evidence with the cached recent run window. - Use omitted comments or runs to decide whether to show More messages. - Preserve run retention, reconnect reconciliation, and expansion of real history. - Offer More run output when a single legacy log exceeds the compact byte window. - Keep first-run native diagnostics accessible through More task actions → Show task activity. - Add regression coverage and update the task feature map. ## Verification - Passed the existing 9,168-test UI suite across 794 files, then all 504 focused task-detail, history-window, real thread, and log-transport tests after adding the activity-menu regression. - Passed workspace build, workspace typechecks, token gates, and map checks. - Reproduced the bug in the earlier UI using a disposable local task. The corrected UI hides the control with zero runs or only the first run. - In the local browser, expanded a long task to all 80 comments. The latest answer appeared once and the disclosure disappeared. No console warnings or errors occurred in that journey. - Expanded a saved 111 KB legacy log through More run output and opened its earlier activity in the local browser. - On a single saved native run, verified no More messages control. Used Show task activity to reveal its earlier tool output; the latest answer remained visible exactly once, with no console warnings or errors. - The first broad local run stopped because the fresh dependency install lacked PostgreSQL library symlinks. The normal package setup repaired that environment issue. - The next broad run hit one workspace-runtime timeout. Its focused rerun passed with unchanged server source; the original failure remains recorded. Stopped the already-failed broad runner after the previous revision passed full PR CI. All final-head build, typecheck, test, and policy CI jobs passed. Both automated reviewers scored the final head 5/5, with no unresolved threads. The separate Entire Gates check is pending because the PR has no active assessed Change for its current comparison. The installed Entire CLI reports no linked project trail and exposes no Change-assessment command; no gate or repository settings were altered. Browser verification used this branch's UI with the existing disposable test-drive API. No agent execution was started. ## Risks Low risk. This changes the recent run cache shape and the disclosure condition. The full run cache remains an array. Tests cover first-run states, observed new runs, omitted older runs, expansion, and reconnect reconciliation. No schema or API changes. A separate run-output control preserves access to a single truncated legacy log. The task menu preserves first-run native activity access without implying older messages exist. ## Model Used OpenAI GPT-6 through Codex, with code execution and browser tools. A more specific model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- maps/feature-map/tasks.md | 2 +- ui/src/components/TaskChatThread.test.tsx | 29 +++++- ui/src/components/TaskChatThread.tsx | 17 +++- .../transcript/useLiveRunTranscripts.test.tsx | 31 +++++- .../transcript/useLiveRunTranscripts.ts | 16 +++- ui/src/pages/IssueDetail.test.tsx | 94 +++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 43 +++++++-- 7 files changed, 216 insertions(+), 16 deletions(-) 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 ? ( <>