Merge remote-tracking branch 'origin/runner-integration-live' into runner/integration

* origin/runner-integration-live:
  fix(ui): refine task chat recovery states

# Conflicts:
#	ui/src/components/IssueChatThread.tsx
#	ui/src/components/TaskChatThread.tsx
#	ui/src/components/task-chat/TaskChatActivityPhase.tsx
#	ui/src/components/task-chat/TaskChatLiveTail.tsx
#	ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx
#	ui/src/components/task-chat/TaskChatRunnerTurn.tsx
#	ui/src/components/task-chat/TaskChatSystemNotice.test.tsx
#	ui/src/components/task-chat/TaskChatThinking.tsx
#	ui/src/components/task-chat/TaskChatThreadView.tsx
#	ui/src/components/task-chat/transcript-adapter.test.ts
#	ui/src/components/task-chat/transcript-adapter.ts
#	ui/src/components/task-side-panel/TaskSidePanel.test.tsx
#	ui/src/pages/IssueDetail.tsx
This commit is contained in:
Dotta committed 2026-08-27 08:29:40 -05:00
commit cd1d1da22c
21 files changed
+392 -118

No files matched your search

+2 -2
View File
@@ -477,7 +477,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
}
if (shouldRunNow && !opts.invokedByRun) {
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
process.env.PAPERCLIP_OPEN_ON_LISTEN ??= "true";
const { runCommand } = await import("./run.js");
await runCommand({ config: configPath, repair: true, yes: true });
return;
@@ -746,7 +746,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
}
if (shouldRunNow && !opts.invokedByRun) {
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
process.env.PAPERCLIP_OPEN_ON_LISTEN ??= "true";
const { runCommand } = await import("./run.js");
await runCommand({ config: configPath, repair: true, yes: true });
return;
@@ -27,6 +27,12 @@ Every attempt allocates a new OS temporary root with a Paperclip home, embedded
database, workspace, private artifacts, logs, port, instance ID, and signing
secrets.
Local runs default to one worker and are intended for a narrow `--id` smoke
test. The high-parallelism full matrix is a GitHub Actions responsibility; an
operator must explicitly raise `--max-parallel` to overlap local stacks. The
harness disables Paperclip's normal post-onboarding desktop browser launch so
Playwright is the only browser owner.
The launcher deletes ambient database URLs. `playwright.config.ts` separately
constructs the Paperclip child environment and strips OpenAI, Anthropic,
OpenRouter, and Daytona variables. It starts
+7 -2
View File
@@ -61,7 +61,7 @@ pnpm test:e2e:runner -- --group native --environment local
pnpm test:e2e:runner -- --profile runner-codex --case message-marker
pnpm test:e2e:runner -- --case plan-revise-accept --group local
pnpm test:e2e:runner -- --case ask-question --group native
pnpm test:e2e:runner -- --all --max-parallel 8
pnpm test:e2e:runner -- --all
```
The catalog contains three cases for every profile/environment pair:
@@ -81,11 +81,16 @@ AND semantics. `--id` is exclusive with dimension selectors and `--all`.
`--headed`, `--ui`, and `--debug` are forwarded to Playwright. An unknown
selector, an empty selection, or a run with no explicit selector exits before
Paperclip starts. `--max-parallel <n>` controls the number of isolated
profile/environment/case harnesses that can overlap (default 4, also configurable
profile/environment/case harnesses that can overlap (default 1, also configurable
with `PAPERCLIP_E2E_MAX_PARALLEL`). Headed/UI/debug runs are forced to one worker.
The Plan case is still sequential internally because its turns share one task;
it runs in parallel with unrelated scenarios.
Use a single `--id` smoke test for routine local verification. Full-matrix
parallelism is intended for GitHub Actions; raising local parallelism starts
multiple Paperclip/Postgres/Chromium stacks and can consume substantial CPU and
memory.
Credential-free checks are:
```bash
+15 -4
View File
@@ -48,6 +48,7 @@ const repositoryRoot = path.resolve(import.meta.dirname, "../..");
const localEnvPath = path.join(repositoryRoot, ".env.runner-e2e.local");
const resultsRoot = path.join(repositoryRoot, "tests/runner-e2e/results");
const activeProcessGroups = new Set<number>();
const activeProcessCleanup = new Map<number, Promise<string | null>>();
let cancelled = false;
function cleanId(value: string) {
@@ -115,8 +116,13 @@ async function terminateProcessGroup(pid: number) {
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.on(signal, () => {
cancelled = true;
for (const pid of activeProcessGroups)
stopProcessGroup(pid, signal === "SIGINT" ? "SIGTERM" : signal);
for (const pid of activeProcessGroups) {
if (activeProcessCleanup.has(pid)) {
stopProcessGroup(pid, "SIGKILL");
continue;
}
activeProcessCleanup.set(pid, terminateProcessGroup(pid));
}
});
}
@@ -253,9 +259,13 @@ async function runProcess(
});
if (timer) clearTimeout(timer);
const processCleanupError = child.pid
? await terminateProcessGroup(child.pid)
? await (activeProcessCleanup.get(child.pid) ??
terminateProcessGroup(child.pid))
: null;
if (child.pid) activeProcessGroups.delete(child.pid);
if (child.pid) {
activeProcessGroups.delete(child.pid);
activeProcessCleanup.delete(child.pid);
}
await new Promise<void>((resolve) => log.end(resolve));
return {
exitCode,
@@ -305,6 +315,7 @@ async function readResult(resultPath: string, fallback: RunnerE2EResult) {
async function copySharedEvidence(privateRoot: string, casePrivateDir: string) {
for (const relative of [
"server.log",
"playwright.log",
"junit.xml",
"html-report",
"blob-report",
+1 -1
View File
@@ -41,7 +41,7 @@ export function parseRunnerSelectors(
headed: false,
ui: false,
debug: false,
maxParallel: Number(process.env.PAPERCLIP_E2E_MAX_PARALLEL ?? "4"),
maxParallel: Number(process.env.PAPERCLIP_E2E_MAX_PARALLEL ?? "1"),
};
for (let index = 0; index < args.length; index += 1) {
const flag = args[index];
+3
View File
@@ -43,6 +43,9 @@ const serverEnvironment = buildPaperclipServerEnvironment(process.env, {
PAPERCLIP_SECRETS_STRICT_MODE: "true",
PAPERCLIP_DB_BACKUP_ENABLED: "false",
PAPERCLIP_DB_BACKUP_DIR: path.join(temporaryRoot, "backups"),
// Onboarding normally opens the app after listen. Browser ownership belongs
// to Playwright in this harness, so never create a developer desktop tab.
PAPERCLIP_OPEN_ON_LISTEN: "false",
});
assertIsolatedServerEnvironment(serverEnvironment, {
temporaryRoot,
+3 -20
View File
@@ -505,18 +505,11 @@ describe("IssueProperties", () => {
document.body.innerHTML = "";
});
it("keeps the Plan tab visible for a planning-mode issue without a plan document", async () => {
it("does not show a Plan tab for a planning-mode issue without a plan document", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableClassicTaskInterface: false,
});
mockIssuesApi.listInteractions.mockResolvedValue([
{
kind: "request_confirmation",
status: "pending",
payload: { target: { type: "issue_document", key: "plan" } },
},
]);
const root = renderProperties(container, {
issue: createIssue({ workMode: "planning" }),
childIssues: [],
@@ -525,18 +518,8 @@ describe("IssueProperties", () => {
});
await waitForAssertion(() => {
expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Plan")).toBe(true);
});
const planTab = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Plan");
await act(async () => {
// Radix Tabs triggers select on mousedown (button 0), not on click.
planTab!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
});
await waitForAssertion(() => {
expect(container.textContent).toContain("This task is in plan mode but no plan document has been written yet.");
expect(container.textContent).toContain("A plan confirmation is pending, but the plan document it should confirm is missing.");
expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Plan")).toBe(false);
expect(container.textContent).not.toContain("This task is in plan mode but no plan document has been written yet.");
});
act(() => root.unmount());
+44
View File
@@ -1726,6 +1726,50 @@ describe("TaskChatThread composer alignment (PAP-498)", () => {
});
});
describe("TaskChatThread no-live-execution-path recovery", () => {
const noLivePathComment = {
id: "comment-no-live-path",
companyId: "company-1",
issueId: "issue-1",
authorType: "system" as const,
authorAgentId: null,
authorUserId: null,
body: "Paperclip retried continuation, but it still has no live execution path.",
presentation: {
kind: "system_notice" as const,
tone: "danger" as const,
title: "No live execution path",
detailsDefaultOpen: false,
},
metadata: null,
createdAt: new Date("2026-08-26T12:00:00.000Z"),
updatedAt: new Date("2026-08-26T12:00:00.000Z"),
};
it("offers Try again only while the task is blocked", async () => {
const onTryAgain = vi.fn();
const props = {
comments: [noLivePathComment],
onAdd: async () => {},
onTryAgainNoLiveExecutionPath: onTryAgain,
showComposer: false,
};
render(<TaskChatThread {...props} issueStatus="blocked" />);
const tryAgain = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-no-live-path-try-again"]',
);
expect(tryAgain).not.toBeNull();
flushSync(() => tryAgain!.click());
await Promise.resolve();
expect(onTryAgain).toHaveBeenCalledTimes(1);
render(<TaskChatThread {...props} issueStatus="todo" />);
expect(container.querySelector('[data-testid="task-chat-no-live-path-try-again"]')).toBeNull();
});
});
describe("TaskChatThread blocker links", () => {
it("shows the direct and server-selected terminal blocker at the top and bottom", () => {
const terminalBlocker = {
@@ -210,9 +210,7 @@ export function IssueProperties({
}
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
}, [taskChatShellEnabled, inline]);
// Plan earns a tab as soon as an issue is in planning mode, even before the
// plan document arrives. This keeps an expected plan surface visible and
// lets its diagnostic empty state explain what is missing.
// A Plan tab represents materialized plan content, not merely planning mode.
// Same query keys as the tab bodies, so these share their cached fetches.
const { data: paneTabPlanDocument } = useIssuePlanDocument(
taskChatShellEnabled ? issue.id : null,
@@ -241,8 +239,7 @@ export function IssueProperties({
const hasPlanTab =
Boolean(paneTabPlanDocument)
|| (paneTabAcceptedPlans?.length ?? 0) > 0
|| paneTabStandaloneDocuments.length > 0
|| issue.workMode === "planning";
|| paneTabStandaloneDocuments.length > 0;
// Artifacts covers the same three sources the tab body composes: work
// products, documents (redundant with the Plan tab, intentionally), and
// agent-created attachments. User comment uploads stay thread-only and
@@ -27,6 +27,7 @@ export function TaskChatActivityPhase({
renderChild,
renderChildren,
autoOpen = true,
defaultOpen = false,
childrenClassName,
showChildRail = false,
appearance = "classic",
@@ -35,6 +36,8 @@ export function TaskChatActivityPhase({
renderChild: (child: TaskChatActivityPhaseItem["items"][number]) => ReactNode;
renderChildren?: (children: TaskChatActivityPhaseItem["items"]) => ReactNode;
autoOpen?: boolean;
/** Keep useful historical content visible in an in-flight transcript. */
defaultOpen?: boolean;
childrenClassName?: string;
/** Draws a nested-activity rail aligned beneath the summary disclosure. */
showChildRail?: boolean;
@@ -42,8 +45,9 @@ export function TaskChatActivityPhase({
appearance?: "classic" | "runner";
}) {
const shouldAutoOpen =
autoOpen &&
(item.active ||
defaultOpen ||
(autoOpen &&
(item.active ||
item.items.some(
(child) =>
(child.kind === "thinking" && child.streaming) ||
@@ -57,7 +61,7 @@ export function TaskChatActivityPhase({
(child.kind === "protocol" &&
child.surface === "runtime_request" &&
child.status === "pending"),
));
)));
const [open, setOpen] = useState(shouldAutoOpen);
useEffect(() => {
if (shouldAutoOpen) setOpen(true);
@@ -135,6 +135,7 @@ describe("TaskChatLiveTail", () => {
{ kind: "thinking", ts: TS, text: "Provider reasoning summary" },
{ kind: "assistant", ts: TS, text: "Visible answer." },
]);
expect(items[0]).toMatchObject({ kind: "thinking", streaming: false });
render(items);
expect(container.textContent).toContain("Visible answer.");
@@ -142,20 +143,15 @@ describe("TaskChatLiveTail", () => {
'[data-testid="task-chat-phase-summary"]',
);
expect(reasoningPhase?.textContent).toContain("Reasoning");
expect(reasoningPhase?.getAttribute("aria-expanded")).toBe("false");
flushSync(() => reasoningPhase?.click());
expect(reasoningPhase?.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("Provider reasoning summary");
const thinking = container.querySelector('[data-testid="task-chat-thinking"]');
const disclosure = thinking?.querySelector<HTMLButtonElement>("button");
const preview = thinking?.querySelector(
'[data-testid="task-chat-thinking-preview"]',
);
expect(disclosure?.getAttribute("aria-expanded")).toBe("false");
expect(preview?.textContent).toBe("Provider reasoning summary");
expect(preview?.classList.contains("task-chat-collapsed-line-fade")).toBe(
true,
);
expect(thinking?.getAttribute("data-state")).toBe("settled");
expect(
thinking?.querySelector('[data-testid="task-chat-thinking-text"]')
?.textContent,
).toContain("Provider reasoning summary");
const phaseChildren = thinking?.closest(
'[data-testid="task-chat-phase-children"]',
);
@@ -174,19 +170,9 @@ describe("TaskChatLiveTail", () => {
expect(
thinking
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.parentElement?.classList.contains("justify-center"),
?.classList.contains("text-muted-foreground/50"),
).toBe(true);
expect(thinking?.querySelector(".task-chat-reasoning-markdown")).toBeNull();
flushSync(() => disclosure?.click());
expect(disclosure?.getAttribute("aria-expanded")).toBe("true");
expect(
thinking?.querySelector(".task-chat-expanded-line-wrap"),
).not.toBeNull();
expect(
thinking?.querySelector(".task-chat-reasoning-markdown")?.textContent,
).toContain("Provider reasoning summary");
});
it("bounds long tool targets and wraps the full value only when expanded", () => {
@@ -105,6 +105,11 @@ function renderTailRow(
<TaskChatActivityPhase
key={item.id}
item={item}
defaultOpen={item.items.some(
(child) =>
child.kind === "thinking" &&
child.lines.some((line) => line.trim().length > 0),
)}
childrenClassName="relative ml-2.5 pl-6"
showChildRail
renderChild={(child) => child.kind === "tool"
@@ -439,18 +439,71 @@ describe("TaskChatRunnerTurn", () => {
?.parentElement?.classList.contains("justify-center"),
).toBe(true);
expect(
thinkingRows?.[0]?.querySelector("button")?.classList.contains(
thinkingRows?.[0]?.querySelector(
'[data-testid="task-chat-thinking-text"]',
)?.textContent,
).toContain("Inspect the current card.");
expect(
thinkingRows?.[0]?.querySelector(".task-chat-reasoning-markdown"),
).toBeNull();
expect(
thinkingRows?.[1]?.querySelector("button")?.classList.contains(
"font-normal",
),
).toBe(true);
});
it("renders only the current reasoning block as active", () => {
render([
{
id: "old-reasoning",
kind: "thinking",
lines: ["Inspect the current card."],
streaming: true,
transcriptIndex: 1,
},
{
id: "read",
kind: "tool",
name: "Read files",
rawName: "Read",
status: "completed",
},
{
id: "current-reasoning",
kind: "thinking",
lines: ["Verify the updated state."],
streaming: true,
transcriptIndex: 3,
},
]);
act(() =>
thinkingRows?.[0]
?.querySelector<HTMLButtonElement>("button")
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
)
?.click(),
);
const oldReasoning = container.querySelector(
'[data-activity-item-id="old-reasoning"]',
);
expect(oldReasoning?.textContent).toBe("Inspect the current card.");
expect(oldReasoning?.querySelector(".shimmer-text")).toBeNull();
expect(
thinkingRows?.[0]?.querySelector(".task-chat-reasoning-markdown"),
oldReasoning?.querySelector('[data-testid="task-chat-thinking-text"]'),
).not.toBeNull();
const currentReasoning = container.querySelector(
'[data-activity-item-id="current-reasoning"]',
);
expect(currentReasoning?.textContent).toContain("Reasoning…");
expect(
currentReasoning
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-(--status-agent-running)"),
).toBe(true);
});
it("does not let a textless reasoning lifecycle remove sticky commentary", () => {
@@ -106,7 +106,7 @@ function RunnerActivityTimeline({ items }: { items: readonly TaskChatItem[] }) {
aria-label="Run activity"
data-testid="task-chat-runner-activity-list"
>
{items.map((item) => (
{items.map((item, index) => (
<li className="min-w-0" key={item.id} data-activity-item-id={item.id}>
{item.kind === "message" ? (
<div
@@ -120,6 +120,7 @@ function RunnerActivityTimeline({ items }: { items: readonly TaskChatItem[] }) {
) : item.kind === "thinking" ? (
<TaskChatThinking
item={item}
active={Boolean(item.streaming) && index === items.length - 1}
defaultOpen={false}
rowClassName="mx-0 px-0"
/>
@@ -10,22 +10,48 @@ export function TaskChatThinking({
item,
defaultOpen,
rowClassName,
active = Boolean(item.streaming),
}: {
item: TaskChatThinkingItem;
/** Activity timelines keep reasoning as one compact row until requested. */
defaultOpen?: boolean;
/** Optional alignment override for a containing activity rail. */
rowClassName?: string;
/** Whether this reasoning block is the runner's current activity. */
active?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen ?? false);
const body = item.lines.join("\n").trim();
const preview = flattenSelfTalk(body);
const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning";
const active = item.streaming === true;
const label = active
? body ? `${baseLabel}…` : "Thinking…"
: item.summaryLabel ?? (body ? baseLabel : "Thought");
if (body && !active) {
return (
<div
className={cn(
"flex min-w-0 items-start gap-1.5 px-1 py-0.5 text-xs font-normal text-muted-foreground",
rowClassName,
)}
data-testid="task-chat-thinking"
data-state="settled"
>
<span className="flex w-5 shrink-0 items-center justify-center">
<Brain
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground/50"
aria-hidden
data-testid="task-chat-thinking-icon"
/>
</span>
<div className="min-w-0 flex-1" data-testid="task-chat-thinking-text">
<MarkdownBody softBreaks linkIssueReferences>{body}</MarkdownBody>
</div>
</div>
);
}
if (!body) {
return (
<div className={cn("-mx-1.5 flex min-w-0 items-center gap-2 px-1.5 py-0.5 text-xs font-normal text-muted-foreground", rowClassName)} data-testid="task-chat-thinking">
@@ -1442,7 +1442,6 @@ describe("paperclip runner semantic channels", () => {
],
{ runId: "run-reasoning-activity", running: true },
);
const thinking = parsed.filter(
(item): item is Extract<TaskChatItem, { kind: "thinking" }> =>
item.kind === "thinking",
@@ -1530,8 +1529,8 @@ describe("paperclip runner semantic channels", () => {
interstitial: true,
channel: "progress",
});
expect(parsed[1]).toMatchObject({ channel: "summary" });
expect(parsed[2]).toMatchObject({ channel: "detail" });
expect(parsed[1]).toMatchObject({ channel: "summary", streaming: false });
expect(parsed[2]).toMatchObject({ channel: "detail", streaming: false });
expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" });
const history = settledRunChildren(parsed);
expect(JSON.stringify(history)).not.toContain("Done.");
@@ -641,6 +641,7 @@ export function transcriptToTaskChatItems(
}
case "assistant": {
if (!entry.text) break;
finishThinking();
const channel = entry.channel;
if (messageIndex >= 0 && messageChannel === channel) {
const it = items[messageIndex];
@@ -7,6 +7,7 @@ import type { Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
taskPanelDocumentTab,
taskPanelPropertiesTab,
writeTaskSidePanelState,
} from "@/lib/task-side-panel-state";
@@ -24,7 +25,7 @@ class ResizeObserverStub {
const fixture = vi.hoisted(() => ({
documents: [] as IssueDocument[] | undefined,
plan: null as IssueDocument | null,
plan: null as IssueDocument | null | undefined,
}));
const routeFixture = vi.hoisted(() => ({
@@ -157,16 +158,13 @@ describe("TaskSidePanel", () => {
expect(container.textContent).toContain("Properties content");
});
it("opens and selects Plan for a planning task", async () => {
it("does not create a Plan tab for planning mode before a plan exists", async () => {
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
expect(container.textContent).toContain("Plan content");
const viewport = container.querySelector<HTMLElement>('[role="tabpanel"][data-side-panel-content-viewport="true"]')!;
const prose = viewport.querySelector<HTMLElement>('[data-side-panel-prose-content="true"]')!;
expect(viewport.className).toContain("overflow-auto");
expect(viewport.className).toContain("scrollbar-while-scrolling");
expect(viewport.className).not.toContain("max-w-4xl");
expect(prose.className).toContain("max-w-4xl");
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
expect(Array.from(container.querySelectorAll('[role="option"]')).some((item) => item.textContent?.includes("Plan"))).toBe(false);
});
it("opens a newly materialized plan until the user has interacted", async () => {
@@ -179,6 +177,7 @@ describe("TaskSidePanel", () => {
it("opens a plan deep link without looping while document metadata loads", async () => {
fixture.documents = undefined;
fixture.plan = undefined;
await render(panel({
issue: issue({ workMode: "planning" }),
@@ -211,6 +210,24 @@ describe("TaskSidePanel", () => {
expect(container.getElementsByTagName("input")[0]?.getAttribute("aria-label")).toBe("Search tabs and resources…");
});
it("removes a Plan tab persisted before a plan document existed", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: {
tabs: [taskPanelPropertiesTab(), taskPanelDocumentTab("plan", "Plan")],
activeTabId: "document:plan",
},
launcherOpen: false,
userInteracted: false,
autoPlanHandled: false,
updatedAt: 1,
});
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull();
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
});
it("opens an ordinary document deep link in a deduplicated document tab", async () => {
fixture.documents = [issueDocument("brief", "Implementation brief")];
await render(panel({ documentDeepLink: { requestId: 1, documentKey: "brief" } }));
@@ -218,15 +218,10 @@ export function TaskSidePanel({
const scrollPositionsRef = useRef(new Map<string, number>());
const userInteractedRef = useRef(restoredRef.current?.userInteracted ?? false);
const autoPlanHandledRef = useRef(restoredRef.current?.autoPlanHandled ?? false);
const initialState = useMemo(() => {
if (restoredRef.current) return restoredRef.current.state;
const tabs = [taskPanelPropertiesTab()];
if (issue.workMode === "planning") tabs.push(taskPanelDocumentTab("plan", "Plan"));
return {
tabs,
activeTabId: issue.workMode === "planning" ? "document:plan" : "properties",
};
}, [issue.workMode]);
const initialState = useMemo(() => restoredRef.current?.state ?? ({
tabs: [taskPanelPropertiesTab()],
activeTabId: "properties",
}), []);
const persist = useCallback((state: { tabs: SidePanelTabRecord<TaskSidePanelTabPayload>[]; activeTabId: string | null }) => {
writeTaskSidePanelState(accountScope, issue.companyId, issue.id, {
@@ -248,6 +243,16 @@ export function TaskSidePanel({
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
}, [inline]);
// Older clients persisted an empty Plan tab merely because the task was in
// planning mode. Remove that stale tab once the plan lookup confirms there
// is no document; a real plan will be opened by the materialization effect
// below when it arrives.
useEffect(() => {
if (planDocument !== null || !controller.tabs.some((tab) => tab.id === "document:plan")) return;
autoPlanHandledRef.current = false;
controller.closeTab("document:plan");
}, [controller.closeTab, controller.tabs, planDocument]);
// Surface a newly materialized plan exactly once until the user takes manual
// control of the tab set. Persisted empty/custom states therefore stay put.
useEffect(() => {
@@ -258,10 +263,14 @@ export function TaskSidePanel({
useEffect(() => {
if (!documentDeepLink) return;
if (
documentDeepLink.documentKey === "plan" &&
planDocument === null
) return;
const document = documents.find((candidate) => candidate.key === documentDeepLink.documentKey);
const label = document ? documentDisplayTitle(document) : documentDeepLink.documentKey === "plan" ? "Plan" : documentDeepLink.documentKey;
controller.openTab(taskPanelDocumentTab(documentDeepLink.documentKey, label));
}, [controller.openTab, documentDeepLink, documents]);
}, [controller.openTab, documentDeepLink, documents, planDocument]);
// Existing URL-backed workspace links remain the external integration API.
useEffect(() => {
@@ -399,13 +408,13 @@ export function TaskSidePanel({
primary.push({ id: "files", label: "Files", icon: <FolderOpen />, shortcut: "G F", alreadyOpen: controller.tabs.some((tab) => tab.id === "files") });
}
const documentItems: SidePanelLauncherItem[] = [
{
...(planDocument ? [{
id: "document:plan",
label: planDocument ? documentDisplayTitle(planDocument) : "Plan",
description: planDocument ? `Revision ${planDocument.latestRevisionNumber ?? 1}` : "No plan document yet",
label: documentDisplayTitle(planDocument),
description: `Revision ${planDocument.latestRevisionNumber ?? 1}`,
icon: <Lightbulb />,
alreadyOpen: controller.tabs.some((tab) => tab.id === "document:plan"),
},
}] : []),
...documents
.filter((document) => document.key !== "plan" && !isArtifactReviewDocumentKey(document.key))
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
@@ -419,8 +428,10 @@ export function TaskSidePanel({
];
const sections: SidePanelLauncherSection[] = [
{ id: "open", label: "Open", items: primary },
{ id: "documents", label: "Task documents", items: documentItems },
];
if (documentItems.length > 0) {
sections.push({ id: "documents", label: "Task documents", items: documentItems });
}
if (fileTabsEnabled) {
const recentItems = recentFilesQuery.data?.state === "available"
? recentFilesQuery.data.items.filter((item) => item.kind === "file").slice(0, 5).map((item) => ({
+117
View File
@@ -50,6 +50,7 @@ const mockIssuesApi = vi.hoisted(() => ({
discardQueuedComment: vi.fn(),
markRead: vi.fn(),
update: vi.fn(),
resolveRecoveryAction: vi.fn(),
previewTreeControl: vi.fn(),
getTreeControlState: vi.fn(),
listTreeHolds: vi.fn(),
@@ -359,6 +360,7 @@ vi.mock("../components/TaskChatThread", () => ({
threadHeader?: ReactNode;
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
runFinalizationActions?: readonly {
id: string;
label: string;
@@ -379,6 +381,15 @@ vi.mock("../components/TaskChatThread", () => ({
{props.stopRunLabel ?? "Stop run"}
</button>
) : null}
{props.onTryAgainNoLiveExecutionPath ? (
<button
type="button"
data-testid="mock-no-live-path-try-again"
onClick={() => void props.onTryAgainNoLiveExecutionPath?.()}
>
Try again
</button>
) : null}
{props.runFinalizationActions?.map((action) => (
<button
key={action.id}
@@ -1872,6 +1883,51 @@ describe("IssueDetail", () => {
mockIssuesApi.update.mockReset();
});
it("moves a blocked task back to todo when the no-live-path notice tries again", async () => {
const activeRecoveryAction = {
id: "recovery-action-1",
} as NonNullable<Issue["activeRecoveryAction"]>;
const issue = createIssue({
status: "blocked",
assigneeAgentId: "agent-1",
activeRecoveryAction,
});
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.resolveRecoveryAction.mockResolvedValue({
issue: { ...issue, status: "todo", activeRecoveryAction: null },
recoveryAction: { ...activeRecoveryAction, status: "resolved" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const tryAgain = container.querySelector<HTMLButtonElement>(
'[data-testid="mock-no-live-path-try-again"]',
);
expect(tryAgain).not.toBeNull();
await act(async () => {
tryAgain!.click();
});
await waitForAssertion(() => {
expect(mockIssuesApi.resolveRecoveryAction).toHaveBeenCalledWith(issue.identifier, {
actionId: activeRecoveryAction.id,
outcome: "restored",
sourceIssueStatus: "todo",
});
});
mockIssuesApi.resolveRecoveryAction.mockReset();
});
it("removes an inbox-origin archived issue and restores it when the toast Undo action is pressed", async () => {
const issue = createIssue({
id: "issue-1",
@@ -2913,6 +2969,67 @@ describe("IssueDetail", () => {
expect(mockClosePanel).toHaveBeenCalled();
});
it("starts a planning-mode task as chat-only until its plan document exists", async () => {
mockSetBreadcrumbToolbar.mockClear();
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(createIssue({
originKind: "manual",
workMode: "planning",
}));
mockIssuesApi.getDocument.mockResolvedValue(null);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockOpenPanel).not.toHaveBeenCalled();
expect(mockClosePanel).toHaveBeenCalled();
expect(
container.querySelector('button[aria-label="Toggle side panel"]'),
).toBeNull();
const toolbar = [...mockSetBreadcrumbToolbar.mock.calls]
.reverse()
.map(([node]) => node)
.find((node) => node !== null);
expect(toolbar).toBeDefined();
});
it("reveals the planning-mode task sidebar when its plan document exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(createIssue({
originKind: "manual",
workMode: "planning",
}));
mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
});
it("keeps the Show properties button clickable on the first task and reveals the sidebar on demand", async () => {
mockSetBreadcrumbToolbar.mockClear();
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
+30 -25
View File
@@ -3096,33 +3096,38 @@ export function IssueDetail() {
);
const panelIssue = useMemo(() => issue ?? null, [issue?.id, issuePanelKey]);
const panelChildIssues = useMemo(() => childIssues, [issuePanelKey]);
// Onboarding first task only: hide the Properties sidebar until a plan exists,
// then reveal it already on the Plan tab. We gate the panel *mount* (withhold
// Planning tasks start as chat-only until a plan exists, then reveal the
// sidebar already on the Plan tab. The onboarding first task keeps the same
// behavior even if its work mode changes. We gate the panel *mount* (withhold
// the panel content) rather than flipping the global `panelVisible` preference
// — that preference persists to localStorage and would leak "hidden" into every
// other task. Every non-first task has originKind !== onboarding_first_task, so
// `suppressPanelForFirstTask` stays false and behavior is unchanged. The user
// can still opt in early via the "Show properties" header button, which sets a
// per-issue override (keyed on the issue id so it resets across navigations).
// other task. The user can still opt in early via the "Show properties" header
// button, which sets a per-issue override (keyed on the issue id so it resets
// across navigations).
const isOnboardingFirstTask =
taskChatShellEnabled &&
issue?.originKind === ONBOARDING_FIRST_TASK_ORIGIN_KIND;
const { data: firstTaskPlanDoc } = useIssuePlanDocument(
isOnboardingFirstTask ? issue?.id : null,
const shouldDeferPanelUntilPlan =
taskChatShellEnabled &&
(isOnboardingFirstTask || issue?.workMode === "planning");
const { data: deferredPanelPlanDoc } = useIssuePlanDocument(
shouldDeferPanelUntilPlan ? issue?.id : null,
);
const [firstTaskPanelOverrideIssueId, setFirstTaskPanelOverrideIssueId] =
const [panelBeforePlanOverrideIssueId, setPanelBeforePlanOverrideIssueId] =
useState<string | null>(null);
const firstTaskPanelOverride =
firstTaskPanelOverrideIssueId !== null &&
firstTaskPanelOverrideIssueId === issue?.id;
const suppressPanelForFirstTask =
isOnboardingFirstTask && !firstTaskPlanDoc && !firstTaskPanelOverride;
const panelBeforePlanOverride =
panelBeforePlanOverrideIssueId !== null &&
panelBeforePlanOverrideIssueId === issue?.id;
const suppressPanelUntilPlan =
shouldDeferPanelUntilPlan &&
!deferredPanelPlanDoc &&
!panelBeforePlanOverride;
const openTaskSidePanel = useCallback(() => {
if (suppressPanelForFirstTask && issue?.id) {
setFirstTaskPanelOverrideIssueId(issue.id);
if (suppressPanelUntilPlan && issue?.id) {
setPanelBeforePlanOverrideIssueId(issue.id);
}
setPanelVisible(true);
}, [issue?.id, setPanelVisible, suppressPanelForFirstTask]);
}, [issue?.id, setPanelVisible, suppressPanelUntilPlan]);
const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(
childIssuesLoading,
childIssues.length,
@@ -4950,7 +4955,7 @@ export function IssueDetail() {
taskChatShellEnabled &&
!isMobile &&
Boolean(issue?.id) &&
(!panelVisible || suppressPanelForFirstTask);
(!panelVisible || suppressPanelUntilPlan);
setBreadcrumbToolbar(
showTaskPanelLauncher ? (
@@ -4972,7 +4977,7 @@ export function IssueDetail() {
openTaskSidePanel,
panelVisible,
setBreadcrumbToolbar,
suppressPanelForFirstTask,
suppressPanelUntilPlan,
taskChatShellEnabled,
]);
@@ -5036,7 +5041,7 @@ export function IssueDetail() {
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!panelIssue || suppressPanelForFirstTask) {
if (!panelIssue || suppressPanelUntilPlan) {
closePanel();
return;
}
@@ -5085,7 +5090,7 @@ export function IssueDetail() {
openPanel,
panelChildIssues,
panelIssue,
suppressPanelForFirstTask,
suppressPanelUntilPlan,
resolvedHasActiveRun,
checkIssueMonitorNow.isPending,
checkIssueMonitorNow.mutate,
@@ -5254,8 +5259,8 @@ export function IssueDetail() {
if (isMobile) {
setMobilePropsOpen(true);
} else {
if (suppressPanelForFirstTask && issue?.id) {
setFirstTaskPanelOverrideIssueId(issue.id);
if (suppressPanelUntilPlan && issue?.id) {
setPanelBeforePlanOverrideIssueId(issue.id);
}
setPanelVisible(true);
}
@@ -5275,7 +5280,7 @@ export function IssueDetail() {
issue?.id,
issueId,
setPanelVisible,
suppressPanelForFirstTask,
suppressPanelUntilPlan,
taskChatShellEnabled,
],
);
@@ -6564,7 +6569,7 @@ export function IssueDetail() {
)}
</Button>
{taskChatShellEnabled ||
(panelVisible && !suppressPanelForFirstTask) ? null : (
(panelVisible && !suppressPanelUntilPlan) ? null : (
<TooltipProvider>
<SidePanelToggleButton
open={false}