fix(tasks): hide empty message history disclosure (#17751)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-26 11:58:30 -05:00
1 parent 125ddcabe5
commit a67a18c171
7 files changed
+216 -16

No files matched your search

+1 -1
View File
@@ -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`
+28 -1
View File
@@ -26,6 +26,7 @@ const transcriptState = vi.hoisted(() => ({
transcriptByRun: new Map(),
isInitialHydrating: false,
hydratedRunIds: undefined as Set<string> | undefined,
truncatedRunIds: new Set<string>(),
}));
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(<TaskChatThread {...props} compactHistory />);
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(<TaskChatThread {...props} compactHistory={false} />);
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(<TaskChatThread comments={[]} onAdd={async () => {}} issueId="saved-chat"
conversationMode draftKey="agent-chat:company:saved-agent" initialHistoryPending />);
+13 -4
View File
@@ -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<typeof IssueChatThread> & {
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<string, NonNullable<ReturnType<typeof logTranscriptByRun.get>>>(),
);
@@ -2824,9 +2827,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
],
);
const historyControl = props.historyControl ?? (
props.compactHistory && props.onExpandHistory
&& logRuns.some((run) => truncatedRunIds?.has(run.id))
? <Button type="button" variant="ghost" size="sm" onClick={props.onExpandHistory}>More run output</Button>
: null
);
const renderBrief = useCallback(
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{props.historyControl}</> : null,
[issueBrief, props.historyControl],
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{historyControl}</> : null,
[issueBrief, historyControl],
);
const assignedAgentForNotice = useMemo(() => {
@@ -3127,7 +3136,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<TaskChatThreadView
items={items}
attachments={attachments}
header={<>{threadHeaderWithBlockers}{!issueBrief ? props.historyControl : null}</>}
header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}</>}
renderInteraction={renderInteraction}
renderBrief={renderBrief}
renderMessageActions={renderMessageActions}
@@ -131,17 +131,18 @@ describe("useLiveRunTranscripts", () => {
});
it("expands a compact log tail without hiding or duplicating the latest output", async () => {
let truncatedRunIds: ReadonlySet<string> = 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<typeof result>) => 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(<Harness limit={32_000} />));
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(<Harness limit={256_000} />));
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<string> = 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(<Harness bytes={100} />));
expect(logMock).toHaveBeenLastCalledWith("run-1", 0, 32_000, expect.anything());
expect(truncatedRunIds.has("run-1")).toBe(false);
await act(async () => root.render(<Harness bytes={100_000} />));
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 });
@@ -144,6 +144,7 @@ export function useLiveRunTranscripts({
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const logReadLimitByRunRef = useRef(new Map<string, number>());
const [truncatedRunIds, setTruncatedRunIds] = useState<ReadonlySet<string>>(new Set());
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
// 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;
+94
View File
@@ -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<HTMLButtonElement>('button[aria-label="More task actions"]')!.click();
});
const activityButton = Array.from(document.body.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>('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<Issue>();
+36 -7
View File
@@ -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<RunForIssue[] | RecentIssueRunWindow>({
queryKey: linkedRunsKey,
queryFn: async () => {
if (historyExpanded) return activityApi.runsForIssue(issueQueryRef);
let recent = await activityApi.recentRunsForIssue(issueQueryRef);
let previous = queryClient.getQueryData<RunForIssue[]>(linkedRunsKey) ?? [];
const previousWindow = queryClient.getQueryData<RecentIssueRunWindow>(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<RunForIssue[]>([...queryKeys.issues.runs(issueQueryRef), "recent"]) : undefined,
placeholderData: historyExpanded ? queryClient.getQueryData<RecentIssueRunWindow>([...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({
<ThreadComponent
{...(!classicTaskInterfaceEnabled ? {
compactHistory: !historyExpanded,
historyControl: !historyExpanded && (hasEarlierMessages || (linkedRuns?.length ?? 0) > 0) ? (
onExpandHistory,
historyControl: !historyExpanded && (hasEarlierMessages || hasEarlierRuns) ? (
<Button type="button" variant="ghost" size="sm" onClick={onExpandHistory}>
More messages
</Button>
@@ -7661,6 +7678,18 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="end">
{taskChatShellEnabled && !historyExpanded ? (
<button
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50"
onClick={() => {
expandHistory();
setMoreOpen(false);
}}
>
<ActivityIcon className="h-3 w-3" />
Show task activity
</button>
) : null}
{streamlinedTaskDetailEnabled ? (
<>
<button