mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Merge current master for required base freshness
Co-Authored-By: Paperclip <noreply@paperclip.ing> * origin/master: fix(tasks): hide empty message history disclosure (#17751)
This commit is contained in:
commit
6bc3859883
7 files changed
+216
-16
No files matched your search
@@ -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`
|
||||
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user