mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
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:
commit
cd1d1da22c
21 files changed
+392
-118
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user