Reconcile sandbox runtime hardening with current recovery

Combine task-scoped workspace adoption with chat isolation, bounded shutdown draining, and provider identity handling. Preserve per-run capability rotation evidence and the new master warm-session transition coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-11 14:15:45 -05:00
commit cd008e3444
1324 files changed
+1488084 -52191

No files matched your search

+5 -5
View File
@@ -1,7 +1,7 @@
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { requiresExecutionReconciliation, type Issue, type IssueRecoveryAction } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import type { TranscriptEntry } from "../adapters";
import { issuesApi } from "../api/issues";
@@ -20,7 +20,7 @@ import { Badge } from "@/components/ui/badge";
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
if (!state) return null;
if (!state || requiresExecutionReconciliation(action.cause)) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
return (
@@ -192,9 +192,9 @@ const AgentRunCard = memo(function AgentRunCard({
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{isActive ? (
{isActive && (!run.execution || run.execution.phase === "working") ? (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="absolute inline-flex h-full w-full motion-safe:animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
</span>
) : (
@@ -203,7 +203,7 @@ const AgentRunCard = memo(function AgentRunCard({
<Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-(length:--text-micro)" />
</div>
<div className="mt-2 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<span>{isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
<span>{isActive ? "Working" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
</div>
</div>
@@ -94,9 +94,11 @@ describe("the connect step's cards", () => {
expect(key!.className).toBe(code!.className);
});
it("masks a key and does not mask a one-time code", () => {
// A provider key is a credential that goes on living; a browser code is
// single-use and about to be pasted somewhere the customer can see.
it("masks only when asked", () => {
// The primitive leaves the choice to each card rather than guessing from
// the label. The key card asks, and so does the Claude card for its code —
// that call site is pinned by the wizard's paste test. What this pins is
// that asking is what does it, and that not asking shows the value.
render(
<>
<OnboardingCardField value="" onChange={() => {}} onSubmit={() => {}} />
+6 -1
View File
@@ -360,7 +360,12 @@ export function OnboardingCardField({
disabled?: boolean;
label?: string;
placeholder?: string;
/** A provider key is a credential; a one-time browser code is not. */
/**
* Dots instead of the value. The key card asks for it because a provider key
* is a credential that goes on living. The Claude card asks too: its code
* stays in the field after the paste so the customer can see something
* landed, and that is all they need to see of it.
*/
masked?: boolean;
/**
* Take focus when the card opens.
+4 -2
View File
@@ -162,6 +162,7 @@ export function AgentActionButtons({
assignLabel = "Assign Task",
runLabel = "Run now",
showStatus = true,
showRun = true,
actionsDisabled = false,
workActionsDisabled = false,
workActionsDisabledReason,
@@ -182,6 +183,7 @@ export function AgentActionButtons({
assignLabel?: string;
runLabel?: string;
showStatus?: boolean;
showRun?: boolean;
actionsDisabled?: boolean;
workActionsDisabled?: boolean;
workActionsDisabledReason?: string;
@@ -398,7 +400,7 @@ export function AgentActionButtons({
<Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{assignLabel}</span>
</Button>
<RunButton
{showRun && <RunButton
onClick={() => {
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
agentAction.mutate("invoke");
@@ -406,7 +408,7 @@ export function AgentActionButtons({
disabled={assignAndRunDisabled}
label={runLabel}
size={size}
/>
/>}
{canRunWithProviderTrace && (
<Button
variant="outline"
@@ -9,7 +9,7 @@ import type { Agent, Environment, UserSecretDefinition } from "@paperclipai/shar
import { getEnvironmentCapabilities } from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ToastProvider } from "../context/ToastContext";
import { AgentConfigForm, AdapterLoginPanel, type AdapterLoginDescriptor } from "./AgentConfigForm";
import { AgentConfigForm, AdapterLoginPanel, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm";
import { defaultCreateValues } from "./agent-config-defaults";
import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
import { ApiError } from "../api/client";
@@ -262,6 +262,11 @@ async function renderForm(
options: {
showAdapterTestEnvironmentButton?: boolean;
content?: "configuration" | "secrets";
environmentVariablesPlacement?: "configuration" | "secrets";
hideInlineSave?: boolean;
onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
} = {},
) {
mockEnvironmentsApi.list.mockResolvedValue(environments);
@@ -288,6 +293,11 @@ async function renderForm(
onSave={onSave}
hidePromptTemplate
content={options.content}
environmentVariablesPlacement={options.environmentVariablesPlacement}
hideInlineSave={options.hideInlineSave}
onDirtyChange={options.onDirtyChange}
onSaveActionChange={options.onSaveActionChange}
onCancelActionChange={options.onCancelActionChange}
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
@@ -729,6 +739,51 @@ describe("AgentConfigForm environment selector", () => {
vi.clearAllMocks();
});
it("promotes environment drafts through the page Save action and discards them through the page Discard action", async () => {
const dirty = vi.fn();
let save: (() => void) | null = null;
let discard: (() => void) | null = null;
const result = await renderForm([], {}, {
content: "secrets", environmentVariablesPlacement: "secrets", hideInlineSave: true,
onDirtyChange: dirty,
onSaveActionChange: action => { save = action; },
onCancelActionChange: action => { discard = action; },
});
roots.push(result.root);
const add = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add variable")!;
await act(async () => add.click());
await act(async () => {
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!, "ONBOARDING_SMOKE");
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!, "true");
});
await flushReact();
expect(dirty).toHaveBeenLastCalledWith(true);
expect([...result.container.querySelectorAll("button")].some(button => button.textContent?.trim() === "Save")).toBe(false);
await act(async () => { await save?.(); });
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ env: { ONBOARDING_SMOKE: { type: "plain", value: "true" } } }) }));
await act(async () => discard?.());
await flushReact();
expect(dirty).toHaveBeenLastCalledWith(false);
expect(result.container.querySelector('input[aria-label="Variable name"]')).toBeNull();
});
it("reads and saves Pi thinking effort using the Pi runtime key", async () => {
const result = await renderForm([], { adapterType: "pi_local", adapterConfig: { model: "openrouter/anthropic/claude-sonnet-4.6", thinking: "high" } });
roots.push(result.root);
const effort = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "high")!;
expect(effort).toBeTruthy();
await act(async () => effort.click());
await flushReact();
const low = [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === "lowlow")!;
expect(low).toBeTruthy();
await act(async () => low.click());
await flushReact();
const save = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!;
await act(async () => save.click());
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ thinking: "low" }) }));
expect(result.onSave.mock.calls[0][0].adapterConfig.effort).toBeUndefined();
});
it("hides the environment override when Local is the only configured environment", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
@@ -817,6 +872,17 @@ describe("AgentConfigForm environment selector", () => {
});
});
it("names the Claude default for new and existing agents without pinning it", async () => {
const environments = [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })];
const existing = await renderForm(environments, { adapterType: "claude_local", adapterConfig: {} });
roots.push(existing.root);
const created = await renderCreateForm(environments, { adapterType: "claude_local", model: "" });
roots.push(created.root);
expect(existing.container.textContent).toContain("Default (claude-opus-5)");
expect(created.container.textContent).toContain("Default (claude-opus-5)");
expect(existing.onSave).not.toHaveBeenCalled();
});
it("keeps secret access out of the main Configuration content", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
@@ -1680,6 +1746,414 @@ describe("AgentConfigForm environment selector", () => {
expect(findButton(container, "Cancel")).toBeFalsy();
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
});
it("reports the account-binding claim upward exactly once when the session authenticates", async () => {
// The authenticated owner read can carry the non-secret Codex
// account-binding claim. The panel hands it to the caller once; the
// caller (the edit-mode form) decides whether a bind is warranted.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "bind-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/bind", code: "BIND-1" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "bind-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-bind-1", companyIdentityDiffers: true },
});
const onAccountBinding = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushUntil(() => onAccountBinding.mock.calls.length > 0);
expect(onAccountBinding).toHaveBeenCalledTimes(1);
expect(onAccountBinding).toHaveBeenCalledWith({
secretId: "secret-bind-1",
companyIdentityDiffers: true,
});
// The panel narrates the bind as its own state, separate from the login's
// success line — the bind is a second save that can still fail.
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
});
it("a failed bind save renders an explicit Retry instead of silently latching the claim", async () => {
// The status poll stops at the terminal state, so a rejected save behind
// a fire-and-forget latch would leave nothing to re-fire the bind. The
// panel keeps the claim and offers Retry.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "bind-session-2",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/bind", code: "BIND-2" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "bind-session-2",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-bind-2", companyIdentityDiffers: true },
});
const onAccountBinding = vi
.fn()
.mockRejectedValueOnce(new Error("save failed"))
.mockResolvedValueOnce(undefined);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushUntil(() => container.textContent?.includes("Could not bind this agent") ?? false);
const retry = findButton(container, "Retry");
expect(retry).toBeTruthy();
await act(async () => {
retry!.click();
});
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
expect(onAccountBinding).toHaveBeenCalledTimes(2);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-bind-2",
companyIdentityDiffers: true,
});
});
it("a second login in the same mounted panel runs its own bind", async () => {
// The terminal state re-enables Sign in without unmounting the panel. The
// bind latch is scoped to the session, so a second cross-account login
// binds again with ITS claim instead of being silently skipped.
mockAgentsApi.startAdapterAuthLogin
.mockResolvedValueOnce({
sessionId: "rebind-s1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/rebind", code: "REBIND-1" },
})
.mockResolvedValueOnce({
sessionId: "rebind-s2",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/rebind", code: "REBIND-2" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockImplementation(
async (_companyId: string, _adapterType: string, sid: string) => ({
sessionId: sid,
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: {
secretId: sid === "rebind-s2" ? "secret-second" : "secret-first",
companyIdentityDiffers: true,
},
}),
);
const onAccountBinding = vi.fn().mockResolvedValue(undefined);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushUntil(() => onAccountBinding.mock.calls.length === 1);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-first",
companyIdentityDiffers: true,
});
const signIn = findButton(container, "Sign in");
expect(signIn).toBeTruthy();
await act(async () => {
signIn!.click();
});
await flushUntil(() => onAccountBinding.mock.calls.length === 2);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-second",
companyIdentityDiffers: true,
});
});
it("a new Sign in stays disabled while the bind save is in flight", async () => {
// Two overlapping bind saves can land out of order — the older save
// finishing last would silently revert the agent to the previous account.
// The panel serializes at its only entry point: Sign in is disabled until
// the current bind settles.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "serialize-s1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/serialize", code: "SER-1" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "serialize-s1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-serialize", companyIdentityDiffers: true },
});
let releaseSave: (() => void) | null = null;
const onAccountBinding = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseSave = resolve;
}),
);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushUntil(() => container.textContent?.includes("Binding this agent") ?? false);
const signInWhileSaving = findButton(container, "Sign in");
expect(signInWhileSaving).toBeTruthy();
expect(signInWhileSaving!.disabled).toBe(true);
await act(async () => {
releaseSave?.();
});
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
const signInAfterSave = findButton(container, "Sign in");
expect(signInAfterSave!.disabled).toBe(false);
});
it("keeps edits made while the bind save is pending after the agent refresh", async () => {
// The bind save runs in the background while the form stays editable. The
// agent refresh that follows the save must subtract only what the save
// persisted — an edit made during "Binding this agent…" survives as
// pending dirty state instead of being wiped with the rest of the overlay.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-keep-edits", companyIdentityDiffers: true },
});
const releaseSaves: Array<() => void> = [];
const onSave = vi.fn(
(_patch: Record<string, unknown>) =>
new Promise<void>((resolve) => {
releaseSaves.push(resolve);
}),
);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
]);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
// The harness swaps the agent object the way the page does after a save
// refresh: same mounted form, new agent identity.
let refreshAgent: (agent: Agent) => void = () => {};
function RefreshHarness() {
const [agent, setAgent] = useState(() => makeAgent({ defaultEnvironmentId: "sandbox-1" }));
refreshAgent = setAgent;
return (
<AgentConfigForm
mode="edit"
agent={agent}
onSave={onSave}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
);
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<RefreshHarness />
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await runTest(container);
await startLogin(container);
await flushUntil(() => onSave.mock.calls.length > 0);
// Rename the agent while the bind save is still in flight.
const nameInput = container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]');
expect(nameInput).toBeTruthy();
setInputValue(nameInput!, "Renamed during bind");
await flushReact();
// An UNRELATED refresh lands while the save is still pending — a poll or
// another actor's save, so this agent does NOT carry the binding yet. It
// must not consume the persisted snapshot (the bind save's own refresh
// still needs it), and it must not subtract the snapshot from the overlay
// either: with the binding entry gone from both the overlay and the
// refreshed agent, an ordinary Save racing the binding refresh would
// replace the config without CODEX_HOME and undo the just-persisted bind.
await act(async () => {
refreshAgent(makeAgent({ defaultEnvironmentId: "sandbox-1" }));
});
await flushReact();
expect(
container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!.value,
).toBe("Renamed during bind");
// An ordinary Save in that window still carries the binding.
const persistedPatch = onSave.mock.calls[0]![0] as Record<string, unknown>;
const saveButton = findButton(container, "Save");
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.click();
});
await flushReact();
expect(onSave.mock.calls.length).toBeGreaterThan(1);
const racingPatch = onSave.mock.calls.at(-1)![0] as Record<string, unknown>;
const racingEnv = (racingPatch.adapterConfig as Record<string, unknown>).env as Record<
string,
unknown
>;
expect(racingEnv.CODEX_HOME).toEqual({
type: "secret_ref",
secretId: "secret-keep-edits",
version: "latest",
});
// Both saves land, and the page refreshes the agent with the persisted
// binding — the same adapter config the bind save sent.
await act(async () => {
for (const release of releaseSaves) release();
});
await flushReact();
await act(async () => {
refreshAgent(
makeAgent({
defaultEnvironmentId: "sandbox-1",
adapterConfig: persistedPatch.adapterConfig as Record<string, unknown>,
}),
);
});
await flushReact();
// The rename survives the refresh instead of reverting to the refreshed
// agent's name.
expect(
container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!.value,
).toBe("Renamed during bind");
});
it("resumes an active login session on mount, adopting its session id and prompt", async () => {
// A page reload loses every piece of local state, so the panel must read
// the caller's active session and adopt it instead of starting a new one.
@@ -3214,6 +3688,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).toContain("Working directory (deprecated)");
expect(labels).toContain("Command");
@@ -3235,6 +3715,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
@@ -3255,6 +3741,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).toContain("ACP session mode");
expect(labels).toContain("ACP non-interactive permissions");
@@ -3270,6 +3762,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
@@ -3286,6 +3784,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
@@ -3296,3 +3800,62 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
expect(adapterFields?.getAttribute("data-hide-instructions-file")).toBe("true");
});
});
describe("subtractPersistedOverlay", () => {
const overlayWith = (adapterConfig: Record<string, unknown>) => ({
identity: {},
adapterConfig,
heartbeat: {},
debug: {},
runtime: {},
});
it("drops an entry whose value is structurally equal even with a new reference", () => {
// An edit-then-restore rebuilds the env object, so a reference compare
// would keep it falsely dirty after the refresh subtracts the snapshot —
// a false "Unsaved changes" state and a redundant full-config save.
const persisted = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
const current = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({});
});
it("keeps an entry the user changed after the snapshot", () => {
const persisted = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
const current = overlayWith({
env: {
CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" },
EXTRA: { type: "plain", value: "added-during-save" },
},
});
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({
env: {
CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" },
EXTRA: { type: "plain", value: "added-during-save" },
},
});
});
it("keeps an entry the snapshot never carried", () => {
const persisted = overlayWith({});
const current = overlayWith({ model: "gpt-5.5" });
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({
model: "gpt-5.5",
});
});
it("compares arrays structurally", () => {
const persisted = overlayWith({ args: ["--flag", "value"] });
const equal = overlayWith({ args: ["--flag", "value"] });
const changed = overlayWith({ args: ["--flag", "other"] });
expect(subtractPersistedOverlay(equal, persisted).adapterConfig).toEqual({});
expect(subtractPersistedOverlay(changed, persisted).adapterConfig).toEqual({
args: ["--flag", "other"],
});
});
});
+5 -1
View File
@@ -14,12 +14,16 @@ describe("supportsAdapterModelRefresh", () => {
});
it("keeps the refresh action hidden for adapters without a live refresh hook", () => {
expect(supportsAdapterModelRefresh("opencode_local")).toBe(false);
expect(supportsAdapterModelRefresh("opencode_local")).toBe(true);
expect(supportsAdapterModelRefresh("paperclip_runner")).toBe(true);
expect(supportsAdapterModelRefresh("process")).toBe(false);
});
});
describe("resolvePaperclipRunnerTransitionModel", () => {
it("preserves Claude custom model IDs", () => {
expect(resolvePaperclipRunnerTransitionModel("claude_local", "custom-claude-model")).toBe("custom-claude-model");
});
it("preserves an explicit model from codex_local", () => {
expect(resolvePaperclipRunnerTransitionModel("codex_local", "gpt-5.5"))
.toBe("gpt-5.5");
File diff suppressed because it is too large. Load diff
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { AgentContextualSidebar } from "./AgentContextualSidebar";
import { queryKeys } from "@/lib/queryKeys";
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
@@ -33,6 +34,16 @@ vi.mock("./SidebarNavItem", () => ({
}));
describe("AgentContextualSidebar", () => {
it.each([false, true])("shows agent Channels only when chat connectors are enabled (%s)", (enabled) => {
const client = new QueryClient();
client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: enabled });
const markup = renderToStaticMarkup(<QueryClientProvider client={client}><MemoryRouter>
<AgentContextualSidebar agentRef="agent" agentId="agent-1" agentName="Agent" />
</MemoryRouter></QueryClientProvider>);
expect(markup.includes('href="/agents/agent/channels"')).toBe(enabled);
expect(markup).toContain('href="/agents/agent/tools"');
client.clear();
});
it("renders local definition/runtime/governance links and scoped Audit links", () => {
const queryClient = new QueryClient();
const markup = renderToStaticMarkup(
+19 -11
View File
@@ -6,6 +6,7 @@ import {
History,
KeyRound,
Library,
MessageSquare,
PlayCircle,
ReceiptText,
Settings2,
@@ -15,6 +16,7 @@ import {
} from "lucide-react";
import { agentsApi } from "@/api/agents";
import { useCompany } from "@/context/CompanyContext";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import { queryKeys } from "@/lib/queryKeys";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
import { SidebarNavItem } from "./SidebarNavItem";
@@ -33,6 +35,7 @@ const localIcons = {
runtime: Settings2,
secrets: ShieldCheck,
tools: Wrench,
channels: MessageSquare,
permissions: ShieldCheck,
"api-keys": KeyRound,
revisions: History,
@@ -49,12 +52,15 @@ export function AgentContextualSidebar({
agentRef,
agentId,
agentName,
labels = { secrets: "Secrets & variables" },
}: {
agentRef: string;
agentId?: string;
agentName?: string;
labels?: Partial<Record<AgentLocalDetailView, string>>;
}) {
const { selectedCompanyId } = useCompany();
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
const shouldResolveAgent = !agentId || !agentName;
const { data: resolvedAgent } = useQuery({
queryKey: [...queryKeys.agents.detail(agentRef), selectedCompanyId ?? null, "contextual-sidebar"],
@@ -90,17 +96,19 @@ export function AgentContextualSidebar({
{section.label}
</p>
<div data-slot="contextual-sidebar-group" className={contextualSidebarStyles.group}>
{section.items.map((item) => {
const href = agentDetailHref(agentRef, item.value);
return (
<SidebarNavItem
key={item.value}
to={href}
label={item.label}
icon={localIcons[item.value]}
/>
);
})}
{section.items
.filter((item) => item.value !== "channels" || chatConnectorsEnabled)
.map((item) => {
const href = agentDetailHref(agentRef, item.value);
return (
<SidebarNavItem
key={item.value}
to={href}
label={labels?.[item.value] ?? item.label}
icon={localIcons[item.value]}
/>
);
})}
</div>
</div>
))}
@@ -89,10 +89,12 @@ export function AttentionInteractionResolver({
interaction: SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction;
selectedClientKeys?: string[];
selectedOptionIds?: string[];
rememberAction?: boolean;
}) =>
issuesApi.acceptInteraction(issueId, input.interaction.id, {
selectedClientKeys: input.selectedClientKeys,
selectedOptionIds: input.selectedOptionIds,
rememberAction: input.rememberAction,
}),
onSuccess: invalidate,
});
@@ -145,8 +147,8 @@ export function AttentionInteractionResolver({
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={(target, selectedClientKeys, selectedOptionIds) =>
acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds }).then(() => undefined)
onAcceptInteraction={(target, selectedClientKeys, selectedOptionIds, rememberAction) =>
acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds, rememberAction }).then(() => undefined)
}
onRejectInteraction={(target, reason) =>
rejectMutation.mutateAsync({ interactionId: target.id, reason }).then(() => undefined)
+3 -2
View File
@@ -349,10 +349,10 @@ function BlockedInboxRow({
const mobileMeta = (
<span className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
<span data-testid="blocked-row-age-mobile">{stoppedAge}</span>
{presentation === "legacy" && <span data-testid="blocked-row-age-mobile">{stoppedAge}</span>}
{ownerName ? (
<>
<span aria-hidden="true">·</span>
{presentation === "legacy" && <span aria-hidden="true">·</span>}
<span
className={cn(isAgent ? "font-medium text-foreground/90" : null)}
data-testid="blocked-row-owner-mobile"
@@ -397,6 +397,7 @@ function BlockedInboxRow({
/>
}
mobileMeta={mobileMeta}
mobileTitleMeta={presentation === "task" ? <span data-testid="blocked-row-age-mobile">{stoppedAge}</span> : undefined}
desktopTrailing={desktopTrailing}
trailingMeta={presentation === "task" && showUpdatedColumn ? stoppedAge : null}
/>
+25 -2
View File
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BreadcrumbProvider, useBreadcrumbs } from "../context/BreadcrumbContext";
import { BreadcrumbBar } from "./BreadcrumbBar";
const viewport = vi.hoisted(() => ({ isMobile: false }));
vi.mock("@/lib/router", () => ({
Link: ({ children, className, to }: { children: ReactNode; className?: string; to: string }) => (
<a className={className} href={to}>{children}</a>
@@ -15,7 +17,7 @@ vi.mock("@/lib/router", () => ({
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
collapsed: false,
isMobile: false,
isMobile: viewport.isMobile,
toggleCollapsed: vi.fn(),
toggleSidebar: vi.fn(),
}),
@@ -44,11 +46,13 @@ function TaskBreadcrumbs({
panelControl,
taskDetailLayout = false,
identifier = "PAP-16679",
sourceHref = "/issues",
}: {
onOpen?: () => void;
panelControl?: { open: boolean; onToggle: () => void };
taskDetailLayout?: boolean;
identifier?: string;
sourceHref?: string;
}) {
const {
setBreadcrumbs,
@@ -58,7 +62,7 @@ function TaskBreadcrumbs({
useEffect(() => {
setBreadcrumbs([
{ label: "Tasks", href: "/issues" },
{ label: "Tasks", href: sourceHref },
{
label: "Hire your first engineer and create a hiring plan",
identifier,
@@ -84,6 +88,7 @@ function TaskBreadcrumbs({
setBreadcrumbPanelControl,
setBreadcrumbToolbar,
setBreadcrumbs,
sourceHref,
]);
return <BreadcrumbBar taskDetailLayout={taskDetailLayout} />;
@@ -97,6 +102,7 @@ describe("BreadcrumbBar", () => {
let root: Root;
beforeEach(() => {
viewport.isMobile = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -107,6 +113,23 @@ describe("BreadcrumbBar", () => {
container.remove();
});
it("shows only the title followed by its identifier for a company-scoped mobile task header", async () => {
viewport.isMobile = true;
await act(async () => {
root.render(
<BreadcrumbProvider>
<TaskBreadcrumbs identifier="TES-3" sourceHref="/TES/issues" />
</BreadcrumbProvider>,
);
});
expect(container.querySelector('a[href="/TES/issues"]')).toBeNull();
const identifier = container.querySelector('[data-slot="task-title-identifier"]');
expect(identifier?.textContent).toBe("TES-3");
expect(identifier?.previousElementSibling?.textContent).toBe("Hire your first engineer and create a hiring plan");
expect(identifier?.previousElementSibling?.className).toContain("truncate");
expect(container.querySelector('button[aria-label="Open sidebar"]')).not.toBeNull();
});
it("renders a page toolbar in the same persistent row as the task breadcrumb", async () => {
const onOpen = vi.fn();
await act(async () => {
+17
View File
@@ -108,6 +108,23 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
</Button>
);
const currentCrumb = breadcrumbs[breadcrumbs.length - 1];
if (isMobile && breadcrumbs[0]?.label === "Tasks" && currentCrumb.identifier) {
return (
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4">
{menuButton}
<h1 className="flex min-w-0 flex-1 items-center gap-1.5 text-sm">
{currentCrumb.leading ? (
<span className="flex shrink-0 items-center">{currentCrumb.leading}</span>
) : null}
<span className="min-w-0 truncate" title={currentCrumb.label}>{currentCrumb.label}</span>
<CrumbIdentifier identifier={currentCrumb.identifier} />
</h1>
{globalToolbarSlots}
</div>
);
}
const breadcrumbTrail = (
<div className="min-w-0 overflow-hidden flex-1">
<Breadcrumb className="min-w-0 overflow-hidden">
@@ -0,0 +1,154 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatConnectorsExperimentalGate } from "./ChatConnectorsExperimentalGate";
import { AgentChannelsPanel } from "./chat/AgentChannelsPanel";
import { ExternallyConnectedTaskBanner } from "./chat/ExternallyConnectedTaskBanner";
import { queryKeys } from "@/lib/queryKeys";
const api = vi.hoisted(() => ({
settings: vi.fn(),
list: vi.fn(),
binding: vi.fn(),
}));
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: { getExperimental: api.settings },
}));
vi.mock("@/api/chatEndpoints", () => ({
chatEndpointsApi: { list: api.list, getIssueBinding: api.binding },
}));
vi.mock("@/lib/router", () => ({
Navigate: ({ to }: { to: string }) => <div data-redirect={to} />,
Link: ({ to, children }: { to: string; children: React.ReactNode }) => (
<a href={to}>{children}</a>
),
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1)
await new Promise((resolve) => setTimeout(resolve, 0));
flushSync(() => {});
}
describe("Chat connectors visibility gate", () => {
let container: HTMLDivElement;
let root: Root;
let client: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
api.settings.mockResolvedValue({ enableChatConnectors: false });
api.list.mockResolvedValue([]);
api.binding.mockResolvedValue(null);
});
afterEach(() => {
flushSync(() => root.unmount());
client.clear();
container.remove();
vi.clearAllMocks();
});
async function render(withClient = true) {
const content = (
<ChatConnectorsExperimentalGate>
<div data-chat-setup>Chat setup</div>
</ChatConnectorsExperimentalGate>
);
flushSync(() =>
root.render(
withClient ? (
<QueryClientProvider client={client}>{content}</QueryClientProvider>
) : (
content
),
),
);
await flushReact();
}
it.each([{}, { enableChatConnectors: false }])(
"redirects missing or disabled settings without mounting setup (%j)",
async (settings) => {
api.settings.mockResolvedValue(settings);
await render();
expect(container.querySelector("[data-chat-setup]")).toBeNull();
expect(
container
.querySelector("[data-redirect]")
?.getAttribute("data-redirect"),
).toBe("/apps");
},
);
it("waits without exposing setup, then enables the route after explicit opt-in", async () => {
let resolve!: (value: unknown) => void;
api.settings.mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
await render();
expect(container.innerHTML).toBe("");
resolve({ enableChatConnectors: true });
await flushReact();
expect(container.querySelector("[data-chat-setup]")).not.toBeNull();
client.setQueryData(queryKeys.instance.experimentalSettings, {
enableChatConnectors: false,
});
await flushReact();
expect(container.querySelector("[data-chat-setup]")).toBeNull();
});
it("fails closed after a settings error", async () => {
api.settings.mockRejectedValue(new Error("unavailable"));
await render();
expect(container.querySelector("[data-chat-setup]")).toBeNull();
expect(container.querySelector("[data-redirect]")).not.toBeNull();
});
it("hides previously enabled setup after a failed settings refetch", async () => {
api.settings.mockResolvedValue({ enableChatConnectors: true });
await render();
expect(container.querySelector("[data-chat-setup]")).not.toBeNull();
api.settings.mockRejectedValue(new Error("unavailable"));
await client.refetchQueries({
queryKey: queryKeys.instance.experimentalSettings,
});
await flushReact();
expect(
client.getQueryData(queryKeys.instance.experimentalSettings),
).toEqual({ enableChatConnectors: true });
expect(container.querySelector("[data-chat-setup]")).toBeNull();
expect(container.querySelector("[data-redirect]")).not.toBeNull();
});
it("defaults off without a query provider and performs no settings request", async () => {
await render(false);
expect(container.querySelector("[data-chat-setup]")).toBeNull();
expect(api.settings).not.toHaveBeenCalled();
});
it("hides cached agent channels and task bindings without fetching chat data while disabled", async () => {
client.setQueryData(queryKeys.chatEndpoints.list("company-1"), [
{ id: "endpoint-1", assignedAgentId: "agent-1", status: "active" },
]);
client.setQueryData(["issue-chat-binding", "company-1", "issue-1"], {
endpointId: "endpoint-1",
conversationId: "conversation-1",
});
flushSync(() =>
root.render(
<QueryClientProvider client={client}>
<AgentChannelsPanel companyId="company-1" agentId="agent-1" />
<ExternallyConnectedTaskBanner
companyId="company-1"
issueId="issue-1"
/>
</QueryClientProvider>,
),
);
await flushReact();
expect(container.innerHTML).toBe("");
expect(api.list).not.toHaveBeenCalled();
expect(api.binding).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import { Navigate } from "@/lib/router";
export function ChatConnectorsExperimentalGate({
children,
}: {
children: ReactNode;
}) {
const { enabled, loaded } = useChatConnectorsEnabled();
if (!loaded) return null;
if (!enabled) return <Navigate to="/apps" replace />;
return <>{children}</>;
}
-61
View File
@@ -1,61 +0,0 @@
import { Rocket, Zap } from "lucide-react";
import { cn } from "../lib/utils";
interface FrontDoorProps {
onChoose: (path: "create" | "grow") => void;
}
export function FrontDoor({ onChoose }: FrontDoorProps) {
return (
<div className="flex flex-col items-center justify-center min-h-(--sz-60vh) px-8">
<div className="text-center mb-10">
<h2 className="text-2xl font-bold tracking-tight">
Welcome to Paperclip
</h2>
<p className="text-sm text-muted-foreground mt-2">
How would you like to get started?
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-lg w-full">
<button
className={cn(
"flex flex-col items-center gap-3 rounded-lg border-2 border-border p-6",
"hover:border-foreground hover:bg-accent/30 transition-all",
"text-center group cursor-pointer",
)}
onClick={() => onChoose("create")}
>
<div className="rounded-full bg-muted/50 p-3 group-hover:bg-accent transition-colors">
<Rocket className="h-6 w-6" />
</div>
<div>
<h3 className="font-semibold text-sm">Build a new organization</h3>
<p className="text-xs text-muted-foreground mt-1">
Begin with a mission, bring on a lead agent, and grow a team of agents to do the work.
</p>
</div>
</button>
<button
className={cn(
"flex flex-col items-center gap-3 rounded-lg border-2 border-border p-6",
"hover:border-foreground hover:bg-accent/30 transition-all",
"text-center group cursor-pointer",
)}
onClick={() => onChoose("grow")}
>
<div className="rounded-full bg-muted/50 p-3 group-hover:bg-accent transition-colors">
<Zap className="h-6 w-6" />
</div>
<div>
<h3 className="font-semibold text-sm">Add agents to your org</h3>
<p className="text-xs text-muted-foreground mt-1">
Bring AI agents into your existing team or workflows.
</p>
</div>
</button>
</div>
</div>
);
}
@@ -31,6 +31,8 @@ interface InlineEntitySelectorProps {
disabled?: boolean;
/** Optional test id forwarded to the trigger button. */
triggerTestId?: string;
/** Optional slot name used by consuming surfaces for scoped presentation rules. */
triggerDataSlot?: string;
}
const EMPTY_RECENT_OPTION_IDS: string[] = [];
@@ -54,6 +56,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
openOnFocus = true,
disabled = false,
triggerTestId,
triggerDataSlot,
},
ref,
) {
@@ -121,6 +124,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
type="button"
disabled={disabled}
data-testid={triggerTestId}
data-slot={triggerDataSlot}
className={cn(
"inline-flex min-w-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none",
className,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+80 -19
View File
@@ -209,7 +209,7 @@ async function flush() {
function findRowTrigger(container: HTMLElement, label: string): HTMLButtonElement | undefined {
const labelSpan = container.querySelector(`[data-property-label="${label}"]`);
const row = labelSpan?.closest('[data-property-row="true"]');
return (row?.querySelector("button") as HTMLButtonElement | null) ?? undefined;
return ((row?.querySelector(`button[aria-label="Edit ${label.toLowerCase()}"]`) ?? row?.querySelector("button")) as HTMLButtonElement | null) ?? undefined;
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
@@ -599,7 +599,9 @@ describe("IssueProperties", () => {
for (const label of ["Labels", "Blocked by", "Subtasks"]) {
const trigger = findRowTrigger(container, label);
const chipStack = trigger?.querySelector("div");
const chipStack = label === "Labels"
? trigger?.querySelector("div")
: trigger?.closest('[data-property-value="true"]')?.querySelector(".flex-col");
expect(chipStack?.classList).toContain("flex-col");
expect(chipStack?.classList).toContain("items-start");
}
@@ -1215,6 +1217,66 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it.each([false, true])("keeps relationship badges mounted as queries settle (inline=%s)", async (inline) => {
let resolveProjects!: (projects: Project[]) => void;
mockProjectsApi.list.mockReturnValue(new Promise<Project[]>((resolve) => { resolveProjects = resolve; }));
const onUpdate = vi.fn();
const issue = createIssue({
blockedBy: [createIssue({ id: "blocker-1", identifier: "PAP-2", status: "in_progress" })],
});
const props = { issue, childIssues: [], onUpdate, inline, sidePanelContentOnly: true };
const { root, queryClient } = renderPropertiesWithQueryClient(container, props);
const link = container.querySelector('a[href="/issues/PAP-2"]');
const remove = container.querySelector('[aria-label="Remove PAP-2 as blocker"]');
expect(link).not.toBeNull();
expect(remove).not.toBeNull();
await flush();
await act(async () => resolveProjects([]));
await flush();
// A parent page can provide a fresh task object after a query refresh.
await act(async () => root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties {...props} issue={{ ...issue, blockedBy: [...issue.blockedBy!] }} />
</QueryClientProvider>,
));
expect(container.querySelector('a[href="/issues/PAP-2"]')).toBe(link);
expect(container.querySelector('[aria-label="Remove PAP-2 as blocker"]')).toBe(remove);
expect(link?.textContent).toContain("in_progress");
expect(onUpdate).not.toHaveBeenCalled();
expect(container.querySelector('[aria-expanded="true"]')).toBeNull();
act(() => root.unmount());
});
it("links relationship status and IDs and removes only the selected blocker with its X", async () => {
const onUpdate = vi.fn();
const blockers = [
createIssue({ id: "issue-2", identifier: "PAP-2", title: "Existing blocker", status: "in_progress" }),
createIssue({ id: "issue-3", identifier: "PAP-3", title: "Keep blocker", status: "todo" }),
];
const root = renderProperties(container, {
issue: createIssue({ blockedBy: blockers }),
childIssues: [createIssue({ id: "child-1", identifier: "PAP-4", status: "done" })],
onUpdate,
inline: true,
});
await flush();
const row = container.querySelector('[data-property-label="Blocked by"]')!.closest('[data-property-row]')!;
const link = row.querySelector<HTMLAnchorElement>('a[href="/issues/PAP-2"]')!;
expect(link).not.toBeNull();
expect(link.textContent).toContain("in_progress");
expect(link.closest("button")).toBeNull();
link.addEventListener("click", (event) => event.preventDefault());
await act(async () => link.click());
expect(onUpdate).not.toHaveBeenCalled();
expect(container.querySelector('input[aria-label="Search tasks to add as blockers"]')).toBeNull();
const remove = row.querySelector<HTMLButtonElement>('button[aria-label="Remove PAP-2 as blocker"]')!;
expect(remove.closest("a")).toBeNull();
await act(async () => remove.click());
expect(onUpdate).toHaveBeenCalledExactlyOnceWith({ blockedByIssueIds: ["issue-3"] });
expect(container.querySelector('a[href="/issues/PAP-4"]')?.textContent).toContain("done");
act(() => root.unmount());
});
it("edits blockers from the blocked-by relationship flyout", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
@@ -1242,7 +1304,7 @@ describe("IssueProperties", () => {
await flush();
const blockerTrigger = findRowTrigger(container, "Blocked by");
expect(blockerTrigger?.textContent).toContain("PAP-2");
expect(blockerTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("PAP-2");
expect(container.textContent).not.toContain("Add blocker");
expect(container.querySelector('input[placeholder="Search tasks..."]')).toBeNull();
@@ -1404,7 +1466,7 @@ describe("IssueProperties", () => {
await flush();
const blockerTrigger = findRowTrigger(container, "Blocked by");
expect(blockerTrigger?.textContent).toContain("PAP-2");
expect(blockerTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("PAP-2");
await act(async () => {
blockerTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -1448,16 +1510,16 @@ describe("IssueProperties", () => {
await flush();
const blockedByTrigger = findRowTrigger(container, "Blocked by");
expect(blockedByTrigger?.textContent).toContain("BLOCK-1");
expect(blockedByTrigger?.textContent).toContain("BLOCK-2");
expect(blockedByTrigger?.textContent).toContain("+5 more");
expect(blockedByTrigger?.textContent).not.toContain("BLOCK-7");
expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-1");
expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-2");
expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more");
expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).not.toContain("BLOCK-7");
const subtasksTrigger = findRowTrigger(container, "Subtasks");
expect(subtasksTrigger?.textContent).toContain("SUB-1");
expect(subtasksTrigger?.textContent).toContain("SUB-2");
expect(subtasksTrigger?.textContent).toContain("+5 more");
expect(subtasksTrigger?.textContent).not.toContain("SUB-7");
expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("SUB-1");
expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("SUB-2");
expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more");
expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).not.toContain("SUB-7");
await act(async () => {
blockedByTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -1641,8 +1703,8 @@ describe("IssueProperties", () => {
});
await flush();
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("+5 more");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more");
const nextBlockedBy = [{
id: "next-blocker",
@@ -1668,9 +1730,9 @@ describe("IssueProperties", () => {
});
await flush();
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("NEXT-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).not.toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).not.toContain("more");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("NEXT-1");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).not.toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).not.toContain("more");
act(() => root.unmount());
});
@@ -2416,8 +2478,7 @@ describe("IssueProperties", () => {
});
await flush();
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
const selectedParentTrigger = findRowTrigger(container, "Parent");
expect(selectedParentTrigger).not.toBeUndefined();
const parentLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(parentLink).not.toBeNull();
@@ -168,6 +168,64 @@ describe("IssueRecoveryActionCard", () => {
);
});
it.each(["active", "escalated", "resolved"] as const)("keeps %s runner recovery in the run log without a card", status => {
const node = render(<IssueRecoveryActionCard action={buildAction({
kind: "active_run_watchdog", cause: "uncertain_external_action", status, ownerType: "board",
})} />);
expect(node.textContent).toBe("");
expect(node.querySelector("section")).toBeNull();
});
it.each(["active", "escalated"] as const)(
"describes a %s board-owned watchdog as a human decision, not a live run",
(status) => {
const node = render(
<IssueRecoveryActionCard
action={buildAction({
kind: "active_run_watchdog",
status,
ownerType: "board",
ownerAgentId: null,
wakePolicy: null,
})}
/>,
);
expect(node.textContent).toContain(
"This recovery needs a human decision. Review the recorded failure and choose the next step.",
);
expect(node.textContent).not.toContain("The active run has been silent");
expect(node.textContent).not.toContain("observing without interrupting");
expect(
node.querySelector("[data-testid='recovery-action-resolve-trigger']"),
).toBeNull();
},
);
it("retains the existing authorized controls for a board-owned watchdog", () => {
const onResolve = vi.fn();
const node = render(
<IssueRecoveryActionCard
action={buildAction({
kind: "active_run_watchdog",
ownerType: "board",
ownerAgentId: null,
wakePolicy: null,
})}
onResolve={onResolve}
/>,
);
click(
node.querySelector("[data-testid='recovery-action-resolve-trigger']"),
);
expect(document.body.textContent).not.toContain("False positive");
click(
[...document.body.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Try again"),
) ?? null,
);
expect(onResolve).toHaveBeenCalledExactlyOnceWith("todo");
});
it("explains issue_graph_liveness in plain language", () => {
const node = render(
<IssueRecoveryActionCard
+12 -1
View File
@@ -1,3 +1,4 @@
import { requiresExecutionReconciliation } from "@paperclipai/shared";
import { useMemo, useState } from "react";
import type {
Agent,
@@ -1003,9 +1004,16 @@ export function IssueRecoveryActionCard({
if (cardState === "resolved" && action.outcome) {
return `Recovery resolved as ${OUTCOME_LABEL[action.outcome] ?? action.outcome}.`;
}
if (
(cardState === "needed" || cardState === "escalated") &&
action.kind === "active_run_watchdog" &&
action.ownerType === "board"
) {
return "This recovery needs a human decision. Review the recorded failure and choose the next step.";
}
if (lineage) return lineageHeadline(lineage);
return KIND_HEADLINE[action.kind] ?? KIND_HEADLINE.missing_disposition;
}, [action.kind, action.outcome, cardState, lineage]);
}, [action.kind, action.outcome, action.ownerType, cardState, lineage]);
// A lane with no path left must not keep advertising a retry that will never run — whether
// the budget ran out or the scheduled attempt simply never fired.
@@ -1051,6 +1059,7 @@ export function IssueRecoveryActionCard({
const showResolveActions = onResolve !== undefined && cardState !== "resolved";
const visibleResolveOptions = RESOLVE_OPTIONS.filter((option) => {
if (option.outcome === "todo" && requiresExecutionReconciliation(action.cause)) return false;
if (option.boardOnly && !canFalsePositive) return false;
return true;
});
@@ -1099,6 +1108,8 @@ export function IssueRecoveryActionCard({
showBreakGlass ||
showRepairAction;
if (requiresExecutionReconciliation(action.cause)) return null;
return (
<section
role="status"
+45 -6
View File
@@ -1,7 +1,9 @@
import { X } from "lucide-react";
import type { ReactNode } from "react";
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "../lib/utils";
import { badgeVariants } from "./ui/badge";
import { StatusIcon } from "./StatusIcon";
export function IssueReferencePill({
@@ -9,29 +11,65 @@ export function IssueReferencePill({
strikethrough,
className,
children,
onRemove,
variant = "mention",
}: {
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
Partial<Pick<IssueRelationIssueSummary, "status">>;
{ status?: string };
strikethrough?: boolean;
variant?: "mention" | "property";
className?: string;
children?: ReactNode;
/** Reserves space for a separate hover/focus action without moving the task link. */
onRemove?: (issueId: string) => void;
}) {
const issueLabel = issue.identifier ?? issue.title;
const classNames = cn(
"paperclip-mention-chip paperclip-mention-chip--issue",
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
variant === "property" || onRemove
? cn(badgeVariants({ variant: "outline" }), "min-w-0 max-w-full shrink font-normal no-underline")
: "paperclip-mention-chip paperclip-mention-chip--issue inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring",
onRemove && "pr-6",
strikethrough && "opacity-60 line-through decoration-muted-foreground",
className,
);
const content = (
<>
{issue.status ? <StatusIcon status={issue.status} className="h-3 w-3 shrink-0" /> : null}
{children !== undefined ? children : <span>{issue.identifier ?? issue.title}</span>}
{children !== undefined ? children : <span className="min-w-0 truncate">{issue.identifier ?? issue.title}</span>}
</>
);
if (!issue.identifier) {
if (onRemove) {
return (
<span className="group/issue-reference relative inline-flex min-w-0 max-w-full">
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
disableIssueQuicklook
data-mention-kind="issue"
className={cn(classNames, "min-w-0 max-w-full")}
title={issue.title}
aria-label={`Task ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground opacity-0 hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/issue-reference:opacity-100 group-focus-within/issue-reference:opacity-100 pointer-coarse:opacity-100"
aria-label={`Remove ${issueLabel} as blocker`}
title={`Remove ${issueLabel} as blocker`}
onClick={(event) => {
event.stopPropagation();
onRemove(issue.id);
}}
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
);
}
if (!issue.identifier && variant === "mention") {
return (
<span
data-mention-kind="issue"
@@ -46,7 +84,8 @@ export function IssueReferencePill({
return (
<Link
to={`/issues/${issueLabel}`}
to={`/issues/${issue.identifier ?? issue.id}`}
disableIssueQuicklook={variant === "property"}
data-mention-kind="issue"
className={classNames}
title={issue.title}
+5 -3
View File
@@ -230,21 +230,23 @@ describe("IssueRow", () => {
act(() => root.unmount());
});
it("keeps canonical leading geometry independent of unread state", () => {
it("keeps read and unread rows aligned while allowing a smaller plain-row gutter", () => {
const root = createRoot(container);
act(() => {
root.render(
<>
<IssueRow issue={createIssue({ id: "read" })} presentation="task" unreadState="hidden" />
<IssueRow issue={createIssue({ id: "plain" })} presentation="task" />
<IssueRow issue={createIssue({ id: "unread" })} presentation="task" unreadState="visible" />
</>,
);
});
const rows = Array.from(container.querySelectorAll('[data-slot="task-row"]'));
const unreadSlot = rows[0]?.querySelector('[data-testid="issue-row-unread-slot"]');
expect(rows).toHaveLength(2);
expect(rows[0]?.className).toBe(rows[1]?.className);
expect(rows).toHaveLength(3);
expect(rows[0]?.className).toBe(rows[2]?.className);
expect(rows[1]?.className).toContain("pl-2 sm:pl-4");
expect(unreadSlot).not.toBeNull();
expect(unreadSlot?.className).toContain("absolute");
expect(unreadSlot?.querySelector('button[aria-label="Mark as read"]')).toBeNull();
+15 -5
View File
@@ -1,3 +1,4 @@
import { requiresExecutionReconciliation } from "@paperclipai/shared";
import type { ReactNode } from "react";
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router";
@@ -47,6 +48,8 @@ export interface IssueRowProps {
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
mobileMeta?: ReactNode;
/** Compact mobile timestamp beside the title in canonical task lists. */
mobileTitleMeta?: ReactNode;
desktopTrailing?: ReactNode;
/**
* Optional pre-fetched external-object summary. Renders a compact severity
@@ -129,6 +132,7 @@ export function IssueRow({
desktopMetaLeading,
desktopLeadingSpacer = false,
mobileMeta,
mobileTitleMeta,
desktopTrailing,
externalObjectSummary,
trailingMeta,
@@ -210,7 +214,7 @@ export function IssueRow({
const recoveryAction = issue.activeRecoveryAction ?? null;
// The row already carries the issue's own scheduled retry, so the chip can tell a retry the
// scheduler is actually running from one whose due time simply passed.
const recoveryIndicator = recoveryAction
const recoveryIndicator = recoveryAction && !requiresExecutionReconciliation(recoveryAction.cause)
? renderRecoveryChip(recoveryAction, selected, { scheduledRetry: issue.scheduledRetry ?? null })
: null;
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
@@ -232,7 +236,8 @@ export function IssueRow({
data-slot="task-row"
data-unread={isUnread ? "true" : undefined}
className={cn(
"group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pl-4 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2",
"group relative flex min-w-0 items-start gap-2 rounded-lg py-2.5 pr-2 text-sm no-underline text-inherit sm:items-center sm:py-2",
showUnreadSlot ? "pl-4" : "pl-2 sm:pl-4",
"[&_button]:relative [&_button]:z-10",
selected ? "bg-accent/50 hover:bg-accent/50" : "hover:bg-accent/50",
checklistCurrentStep && "bg-primary/5",
@@ -262,7 +267,7 @@ export function IssueRow({
</span>
) : null}
<span data-slot="task-row-leading" className="flex shrink-0 items-center gap-1 pt-px sm:pt-0">
<span data-slot="task-row-leading" className="flex shrink-0 items-start self-stretch gap-1 pt-px sm:items-center sm:pt-0">
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => {
const gapForChevron = chevronInGuide && level === treeGuides - 1;
@@ -271,7 +276,7 @@ export function IssueRow({
key={`task-guide-${level}`}
data-slot="task-row-tree-guide"
aria-hidden="true"
className="relative hidden w-4 shrink-0 self-stretch sm:block"
className="relative block w-4 shrink-0 self-stretch"
>
<span
data-slot="task-row-tree-connector"
@@ -305,7 +310,7 @@ export function IssueRow({
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-start gap-1.5 sm:items-center">
<span data-slot="task-row-title-cluster" className="flex min-w-0 flex-1 items-baseline gap-1.5 sm:items-center">
<span
data-slot="task-row-title"
className={cn(
@@ -317,6 +322,11 @@ export function IssueRow({
{issue.title}{titleSuffix}
</span>
{recoveryIndicator}
{mobileTitleMeta ? (
<span className="ml-auto shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground sm:hidden">
{mobileTitleMeta}
</span>
) : null}
</span>
{checklistDependencyChips ? (
<span className="flex flex-wrap gap-1">{checklistDependencyChips}</span>
@@ -132,10 +132,10 @@ describe("IssueThreadInteractionCard", () => {
});
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeTruthy();
expect(host.textContent).toContain("Connect / Use existing");
expect(host.textContent).toContain("Connect");
expect(host.textContent).toContain("Not now");
const loadButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Connect / Use existing"),
button.textContent?.trim() === "Connect",
);
await act(async () => {
loadButton?.click();
@@ -159,7 +159,7 @@ describe("IssueThreadInteractionCard", () => {
});
expect(host.querySelector('[data-testid="connection-intent-waiting"]')).toBeTruthy();
expect(host.textContent).not.toContain("Connect / Use existing");
expect(Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim())).not.toContain("Connect");
expect(host.textContent).not.toContain("Not now");
});
@@ -1008,79 +1008,56 @@ describe("IssueThreadInteractionCard", () => {
});
describe("IssueThreadInteractionCard tool-action card", () => {
it("selects the pending state with the Approve & run affordance and identity header", () => {
const host = renderCard({
interaction: pendingToolActionWriteInteraction,
onAcceptInteraction: vi.fn(),
onRejectInteraction: vi.fn(),
});
// Pending eyebrow, never a bare "Accepted".
expect(host.textContent).toContain("Awaiting approval");
// Identity header: tool display name + WRITE risk badge + app/tool sub-line.
expect(host.textContent).toContain("Append row to spreadsheet");
expect(host.textContent).toContain("WRITE");
expect(host.textContent).toContain("Google Sheets");
// Primary CTA is "Approve & run" (approve = run), plus the hint + countdown.
const approve = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Approve & run"),
);
expect(approve).toBeTruthy();
expect(host.textContent).toContain("Approving runs this action now.");
expect(host.textContent).toContain("Approval expires in");
// Technical details drawer is present but collapsed by default (hash hidden).
expect(host.textContent).toContain("Technical details");
it("shows only the request description, app icon, and decision controls", () => {
const host = renderCard({ interaction: pendingToolActionWriteInteraction, onAcceptInteraction: vi.fn(), onRejectInteraction: vi.fn() });
expect(host.textContent).toContain("Add 1 row");
expect(host.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe("Google Sheets");
expect(host.textContent).toContain("Approve & run");
expect(host.textContent).toContain("Decline");
expect(host.textContent).not.toContain("Append row to spreadsheet");
expect(host.textContent).not.toContain("Technical details");
expect(host.textContent).not.toContain("Expires");
expect(host.textContent).not.toContain("args hash");
});
it("uses the destructive risk badge and a destructive primary button", () => {
const host = renderCard({
interaction: pendingToolActionDestructiveInteraction,
onAcceptInteraction: vi.fn(),
onRejectInteraction: vi.fn(),
});
it("keeps reviewed argument values visible across preview paragraphs", () => {
const interaction = structuredClone(pendingToolActionWriteInteraction);
interaction.payload.toolAction!.previewMarkdown = "Add a row.\n\n**Title:** Launch checklist";
const host = renderCard({ interaction });
expect(host.textContent).toContain("Launch checklist");
});
expect(host.textContent).toContain("DESTRUCTIVE");
const approve = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Approve & run"),
);
it("keeps the destructive request warning and approval styling", () => {
const host = renderCard({ interaction: pendingToolActionDestructiveInteraction, onAcceptInteraction: vi.fn(), onRejectInteraction: vi.fn() });
expect(host.textContent).toContain("cannot be undone");
const approve = Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Approve & run");
expect(approve?.getAttribute("data-variant")).toBe("destructive");
});
it("reveals redacted args and the hash when the technical drawer is opened", () => {
const host = renderCard({
interaction: pendingToolActionWriteInteraction,
onAcceptInteraction: vi.fn(),
onRejectInteraction: vi.fn(),
});
const trigger = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Technical details"),
);
act(() => {
(trigger as HTMLButtonElement).click();
});
expect(host.textContent).toContain("args hash");
expect(host.textContent).toContain("sha256:9f2c1a7be4d0c8a3");
// Redacted arguments render verbatim, never raw secrets.
expect(host.textContent).toContain("[redacted]");
it("declines in one click without an optional reason form", async () => {
const reject = vi.fn();
const host = renderCard({ interaction: pendingToolActionWriteInteraction, onRejectInteraction: reject });
const decline = Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Decline");
await act(async () => { decline?.click(); });
expect(reject).toHaveBeenCalledExactlyOnceWith(pendingToolActionWriteInteraction);
expect(host.querySelector("textarea")).toBeNull();
});
it("renders the approved-running state with a spinner and no action buttons", () => {
const host = renderCard({ interaction: runningToolActionInteraction });
expect(host.textContent).toContain("Running…");
expect(host.textContent).toContain("running the action now");
expect(host.textContent).toContain("Approved · Running");
expect(host.textContent).not.toContain("Approve & run");
expect(host.querySelector(".animate-spin")).toBeTruthy();
});
it("renders the executed state with a result summary and never reads Accepted", () => {
it("keeps the executed result collapsed and never reads Accepted", () => {
const host = renderCard({ interaction: executedToolActionInteraction });
expect(host.textContent).toContain("Executed");
expect(host.textContent).toContain("Row 42 added");
expect(host.textContent).toContain("Succeeded");
expect(host.textContent).not.toContain("Row 42 added");
expect(host.querySelector('button[aria-label="Show result details"]')?.getAttribute("aria-expanded")).toBe("false");
expect(host.textContent).not.toContain("Accepted");
const link = Array.from(host.querySelectorAll("a")).find((a) =>
a.textContent?.includes("View result"),
@@ -1088,11 +1065,23 @@ describe("IssueThreadInteractionCard tool-action card", () => {
expect(link?.getAttribute("href")).toContain("docs.google.com");
});
it("expands stored results as formatted JSON on demand", async () => {
const interaction = structuredClone(executedToolActionInteraction);
interaction.result!.toolAction!.resultSummary = '{"pages":["Roadmap","Meeting notes"]}';
const host = renderCard({ interaction });
expect(host.textContent).not.toContain("Roadmap");
const toggle = host.querySelector('button[aria-label="Show result details"]') as HTMLButtonElement;
await act(async () => toggle.click());
expect(host.querySelector("pre")?.textContent).toBe(JSON.stringify({ pages: ["Roadmap", "Meeting notes"] }, null, 2));
expect(toggle.getAttribute("aria-expanded")).toBe("true");
await act(async () => toggle.click());
expect(host.textContent).not.toContain("Roadmap");
});
it("distinguishes failed (ran + connector error) from declined (did not run)", () => {
const failed = renderCard({ interaction: failedToolActionInteraction });
expect(failed.textContent).toContain("Failed");
expect(failed.textContent).toContain("insufficient_permission");
expect(failed.textContent).toContain("but the connector returned an error");
expect(failed.textContent).toContain("Execution failed");
expect(failed.textContent).toContain(failedToolActionInteraction.result!.toolAction!.errorMessage);
act(() => root?.unmount());
failed.remove();
@@ -1100,19 +1089,14 @@ describe("IssueThreadInteractionCard tool-action card", () => {
const declined = renderCard({ interaction: declinedToolActionInteraction });
expect(declined.textContent).toContain("Declined");
expect(declined.textContent).toContain("did");
expect(declined.textContent).toContain("not");
expect(declined.textContent).toContain("run");
expect(declined.textContent).toContain("use the CRM sync instead");
expect(declined.textContent).not.toContain("Approve & run");
});
it("renders the expired state with the 60-minute rule and a recovery path", () => {
it("renders the expired state without decision controls", () => {
const host = renderCard({ interaction: expiredToolActionInteraction });
expect(host.textContent).toContain("Expired");
expect(host.textContent).toContain("no one responded within 60 minutes");
expect(host.textContent).toContain("the agent can request approval again");
expect(host.textContent).not.toContain("Approve & run");
});
@@ -1438,7 +1422,7 @@ describe("IssueThreadInteractionCard connection-intent card", () => {
});
expect(host.querySelector('[data-testid="connection-intent-actions"]')).not.toBeNull();
const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
expect(labels).toContain("Connect / Use existing");
expect(labels).toContain("Connect");
expect(labels).toContain("Not now");
expect(host.textContent).toContain("Access is added only for this agent");
});
+103 -416
View File
@@ -41,6 +41,8 @@ import { Textarea } from "./ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { ProposalJustification } from "../pages/secrets/proposal-review";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
import { AppLogo } from "@/pages/apps/AppLogo";
import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody";
const OTHER_ANSWER_ID = "__paperclip_other__";
@@ -99,6 +101,7 @@ interface IssueThreadInteractionCardProps {
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
rememberAction?: boolean,
) => Promise<void> | void;
onRejectInteraction?: (
interaction:
@@ -430,6 +433,7 @@ type ToolActionCardState =
| "running"
| "executed"
| "failed"
| "cancelled"
| "declined"
| "expired";
@@ -445,6 +449,7 @@ function toolActionCardState(
const execStatus = interaction.result?.toolAction?.status ?? null;
if (interaction.status === "pending") return "pending";
if (interaction.status === "rejected") return "declined";
if (interaction.status === "cancelled") return "cancelled";
if (interaction.status === "expired") return "expired";
// Terminal execution outcomes take precedence over the coarse interaction
// status so a self-resolving "running…" advances to its real result.
@@ -495,6 +500,8 @@ function toolActionStatusClasses(state: ToolActionCardState): {
Icon: XCircle,
dimmed: true,
};
case "cancelled":
return { shell: "border-2 border-border bg-transparent", badge: "border-border bg-muted text-muted-foreground", label: "Cancelled", Icon: XCircle, dimmed: true };
case "expired":
return {
shell: "border-2 border-border bg-transparent",
@@ -513,48 +520,6 @@ function toolActionStatusClasses(state: ToolActionCardState): {
}
}
function toolActionRiskBadge(risk: "write" | "destructive") {
if (risk === "destructive") {
return {
label: "DESTRUCTIVE",
Icon: TriangleAlert,
className:
"border-red-500/60 bg-red-500/10 text-red-900 dark:bg-red-500/15 dark:text-red-100",
};
}
return {
label: "WRITE",
Icon: AlertTriangle,
className:
"border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100",
};
}
function toolActionInitial(payload: {
appDisplayName: string | null;
toolDisplayName: string;
}): string {
const source = payload.appDisplayName?.trim() || payload.toolDisplayName.trim();
return source ? source.charAt(0).toUpperCase() : "?";
}
function formatToolActionCountdown(expiresAt: string, nowMs: number): {
text: string;
urgent: boolean;
} | null {
const expiresMs = new Date(expiresAt).getTime();
if (Number.isNaN(expiresMs)) return null;
const remainingMs = expiresMs - nowMs;
if (remainingMs <= 0) {
return { text: "Approval window closed · auto-declines any moment", urgent: true };
}
const minutes = Math.ceil(remainingMs / 60000);
return {
text: `Approval expires in ${minutes} min · auto-declines if not answered`,
urgent: minutes <= 5,
};
}
function TaskField({
label,
value,
@@ -1666,235 +1631,56 @@ function RequestConfirmationResolution({
return null;
}
function ToolActionIdentityHeader({
payload,
state,
}: {
payload: NonNullable<RequestConfirmationInteraction["payload"]["toolAction"]>;
state: ToolActionCardState;
}) {
const risk = toolActionRiskBadge(payload.risk);
const RiskIcon = risk.Icon;
const dimmed = state === "declined" || state === "expired";
const subParts = [payload.appDisplayName, payload.toolName].filter(
(part): part is string => Boolean(part && part.trim()),
);
return (
<div className={cn("flex items-start gap-3", dimmed && "opacity-60 grayscale")}>
<div
aria-hidden
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/60 text-base font-semibold text-foreground"
>
{toolActionInitial(payload)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-base font-bold leading-tight text-foreground">
{payload.toolDisplayName}
</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
risk.className,
)}
>
<RiskIcon className="h-3 w-3" />
{risk.label}
</span>
</div>
{subParts.length > 0 ? (
<div className="mt-1 truncate font-mono text-(length:--text-compact) text-muted-foreground">
{subParts.join(" · ")}
</div>
) : null}
</div>
</div>
);
}
function ToolActionTechnicalDetails({
payload,
}: {
payload: NonNullable<RequestConfirmationInteraction["payload"]["toolAction"]>;
}) {
const [open, setOpen] = useState(false);
const hasArgs = payload.argumentsSummaryJson.trim().length > 0;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-sm py-1 text-left text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40">
{open ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Technical details
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{hasArgs ? (
<pre className="max-h-64 overflow-auto rounded-sm border border-border/70 bg-muted/40 p-3 font-mono text-xs leading-5 text-foreground">
{payload.argumentsSummaryJson}
</pre>
) : null}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-semibold uppercase tracking-(--tracking-eyebrow) text-(length:--text-nano)">
args hash
</span>
<code className="truncate font-mono">{payload.argumentsHash}</code>
</div>
</CollapsibleContent>
</Collapsible>
);
}
function ToolActionResolution({
state,
interaction,
resolvedByLabel,
requestedByLabel,
}: {
state: ToolActionCardState;
interaction: RequestConfirmationInteraction;
resolvedByLabel: string | null;
requestedByLabel: string;
}) {
const result = interaction.result?.toolAction ?? null;
const who = resolvedByLabel ?? "the board";
const when = interaction.resolvedAt
? formatDateTime(interaction.resolvedAt)
: result?.updatedAt
? formatDateTime(result.updatedAt)
: null;
const whenSuffix = when ? ` at ${when}` : "";
if (state === "running") {
return (
<div
aria-live="polite"
className="flex items-start gap-2 rounded-sm border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100"
>
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin" />
<div className="space-y-1 leading-6">
<div className="font-medium">Approved by {who} — running the action now</div>
<p className="text-amber-900/80 dark:text-amber-100/80">
The action is executing server-side with the exact arguments you approved.
</p>
</div>
</div>
);
const result = interaction.result?.toolAction;
const [resultOpen, setResultOpen] = useState(false);
const output = state === "executed" ? result?.resultSummary?.trim() : null;
let formattedOutput = output;
if (output) {
try { formattedOutput = JSON.stringify(JSON.parse(output), null, 2); } catch { /* Plain-text results remain readable. */ }
}
if (state === "executed") {
const summary = result?.resultSummary?.trim();
const href = result?.resultHref?.trim();
return (
<div
aria-live="polite"
className="space-y-2 rounded-sm border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-900 dark:text-green-100"
>
<div className="flex items-start gap-2 leading-6">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium">Executed · approved by {who}{whenSuffix}</div>
<p className="text-green-900/80 dark:text-green-100/80">
{requestedByLabel} was resumed with this result.
</p>
</div>
</div>
{summary ? (
<div className="rounded-sm border border-green-500/40 bg-background/60 px-3 py-2 font-medium text-foreground">
{summary}
</div>
) : (
<div className="rounded-sm border border-green-500/40 bg-background/60 px-3 py-2 text-foreground">
Executed successfully.
</div>
)}
{href ? (
<Button asChild size="sm" variant="outline" className="h-7 px-2">
<a href={href} target="_blank" rel="noreferrer">
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
View result
</a>
</Button>
) : null}
</div>
);
}
if (state === "failed") {
const errorText = result?.errorMessage?.trim();
const errorCode = result?.errorCode?.trim();
return (
<div
aria-live="polite"
className="space-y-2 rounded-sm border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100"
>
<div className="flex items-start gap-2 leading-6">
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
<div>
<div className="font-medium">Failed · approved by {who}{whenSuffix}</div>
<p className="text-amber-900/80 dark:text-amber-100/80">
You approved it and it ran, but the connector returned an error.{" "}
{requestedByLabel} was resumed with this error.
</p>
</div>
</div>
{errorText || errorCode ? (
<div className="rounded-sm border border-red-500/50 bg-red-500/10 px-3 py-2 text-red-900 dark:text-red-100">
{errorCode ? (
<div className="text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-red-700 dark:text-red-300">
{errorCode}
</div>
) : null}
{errorText ? (
<p className={cn("leading-6", errorCode && "mt-1")}>{errorText}</p>
) : null}
</div>
) : null}
</div>
);
}
if (state === "declined") {
const reason = interaction.result?.reason?.trim();
return (
<div className="space-y-2 rounded-sm border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-900 dark:text-red-100">
<div className="flex items-start gap-2 leading-6">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium">Declined by {who}{whenSuffix}</div>
<p className="text-red-900/80 dark:text-red-100/80">
The action did <strong>not</strong> run. {requestedByLabel} was resumed with
your reason and told not to retry the same call.
</p>
</div>
</div>
{reason ? (
<div className="rounded-sm border border-red-500/40 bg-background/60 px-3 py-2 text-foreground">
<MarkdownBody>{reason}</MarkdownBody>
</div>
) : null}
</div>
);
}
// expired
const labels = {
pending: "Waiting for approval",
running: "Approved · Running…",
executed: "Succeeded",
failed: "Execution failed",
declined: "Declined",
expired: "Expired",
cancelled: "Cancelled",
};
const Icon = state === "running" ? Loader2 : state === "executed" ? CheckCircle2 : state === "failed" ? AlertTriangle : state === "expired" ? Clock : MinusCircle;
const detail = state === "failed" ? result?.errorMessage?.trim() || "The action could not complete."
: state === "declined" ? interaction.result?.reason?.trim()
: null;
const status = <>
<Icon className={cn("h-3.5 w-3.5 shrink-0", state === "running" && "animate-spin", state === "failed" && "text-destructive")} />
{labels[state]}
{result?.rememberedAction ? " · Always allowed" : ""}
</>;
return (
<div className="space-y-1 rounded-sm border border-border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
<div className="flex items-start gap-2 leading-6">
<Clock className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-foreground">
Expired{when ? ` at ${when}` : ""} — no one responded within 60 minutes
</div>
<p>
The action did <strong>not</strong> run. If it's still needed, the agent can
request approval again — a fresh card will appear.
</p>
</div>
</div>
<div className="space-y-1 text-sm text-muted-foreground" aria-live="polite">
{output ? (
<Collapsible open={resultOpen} onOpenChange={setResultOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex items-center gap-1.5 rounded-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={resultOpen ? "Hide result details" : "Show result details"}>
{status}<ChevronDown className={cn("h-3 w-3", resultOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-foreground">{formattedOutput}</pre>
</CollapsibleContent>
</Collapsible>
) : <p className="flex items-center gap-1.5">{status}</p>}
{detail ? <p className={cn("break-words", state === "failed" && "text-destructive")}>{detail}</p> : null}
{state === "executed" && result?.resultHref?.trim() ? (
<a className="inline-flex items-center gap-1 underline underline-offset-4 hover:text-foreground" href={result.resultHref} target="_blank" rel="noreferrer">View result<ExternalLink className="h-3 w-3" /></a>
) : null}
</div>
);
}
@@ -1902,54 +1688,33 @@ function ToolActionResolution({
function RequestToolActionCard({
interaction,
state,
resolvedByLabel,
requestedByLabel,
onAcceptInteraction,
onRejectInteraction,
externalReferences,
}: {
interaction: RequestConfirmationInteraction;
state: ToolActionCardState;
resolvedByLabel: string | null;
requestedByLabel: string;
onAcceptInteraction?: (
interaction: RequestConfirmationInteraction,
) => Promise<void> | void;
onRejectInteraction?: (
interaction: RequestConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onAcceptInteraction?: IssueThreadInteractionCardProps["onAcceptInteraction"];
onRejectInteraction?: (interaction: RequestConfirmationInteraction, reason?: string) => Promise<void> | void;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const payload = interaction.payload.toolAction!;
const [rejecting, setRejecting] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
const [working, setWorking] = useState<"accept" | "always" | "reject" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const resolutionErrorMessage = useResolutionErrorMessage();
const [nowMs, setNowMs] = useState(() => Date.now());
const isPending = state === "pending";
const isDestructive = payload.risk === "destructive";
const variant = payload.risk === "destructive" ? "destructive" : "cta";
useEffect(() => {
if (!isPending) return;
const timer = setInterval(() => setNowMs(Date.now()), 30000);
return () => clearInterval(timer);
}, [isPending]);
if (!isPending) setWorking(null);
}, [interaction.id, isPending]);
useEffect(() => {
if (state !== "pending") {
setRejecting(false);
setWorking(null);
}
}, [interaction.id, state]);
async function handleAccept() {
if (!onAcceptInteraction) return;
setWorking("accept");
async function decide(decision: "accept" | "always" | "reject") {
setWorking(decision);
setActionError(null);
try {
await onAcceptInteraction(interaction);
if (decision === "reject") await onRejectInteraction?.(interaction);
else await onAcceptInteraction?.(interaction, undefined, undefined, decision === "always");
} catch (error) {
setActionError(resolutionErrorMessage(error));
} finally {
@@ -1957,125 +1722,44 @@ function RequestToolActionCard({
}
}
async function handleReject() {
if (!onRejectInteraction) return;
setWorking("reject");
setActionError(null);
try {
await onRejectInteraction(interaction, rejectReason.trim() || undefined);
setRejecting(false);
} catch (error) {
setActionError(resolutionErrorMessage(error));
} finally {
setWorking(null);
}
}
const countdown = isPending ? formatToolActionCountdown(payload.expiresAt, nowMs) : null;
return (
<div className="space-y-4">
<ToolActionIdentityHeader payload={payload} state={state} />
<div className="text-sm leading-6 text-foreground">
<MarkdownBody externalReferences={externalReferences}>
{payload.previewMarkdown}
</MarkdownBody>
<div className="text-foreground">
<div className="flex items-start gap-3">
<span role="img" aria-label={payload.appDisplayName || payload.toolDisplayName}>
<AppLogo name={payload.appDisplayName || payload.toolDisplayName} size={36} />
</span>
<div className="min-w-0 flex-1 space-y-1 text-sm">
<MarkdownBody externalReferences={externalReferences}>{payload.previewMarkdown || payload.toolDisplayName}</MarkdownBody>
{!isPending ? <ToolActionResolution state={state} interaction={interaction} /> : null}
</div>
</div>
<ToolActionTechnicalDetails payload={payload} />
{isPending ? (
<>
{countdown ? (
<div
className={cn(
"flex items-center gap-2 text-(length:--text-micro) font-medium",
countdown.urgent ? "text-amber-700 dark:text-amber-300" : "text-muted-foreground",
)}
>
<Clock className="h-3.5 w-3.5" />
{countdown.text}
</div>
) : null}
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={isDestructive ? "destructive" : "cta"}
disabled={!onAcceptInteraction || working !== null}
onClick={() => void handleAccept()}
>
{working === "accept" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Approving…
</>
) : (
"Approve & run"
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => setRejecting((current) => !current)}
>
Decline
</Button>
<span className="text-(length:--text-micro) text-muted-foreground">
Approving runs this action now.
</span>
</div>
{rejecting ? (
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
<Textarea
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder="Optional: tell the agent why, so it doesn't retry the same call."
className="min-h-20 bg-background text-sm"
/>
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => setRejecting(false)}
>
Cancel
<div className="mt-3 flex justify-end gap-2">
<Button size="sm" variant="ghost" disabled={!onRejectInteraction || working !== null} onClick={() => void decide("reject")}>
{working === "reject" ? "Declining…" : "Decline"}
</Button>
<div className="inline-flex" role="group" aria-label="Approve request">
<Button size="sm" variant={variant} className={payload.rememberActionScope ? "rounded-r-none" : undefined} disabled={!onAcceptInteraction || working !== null} onClick={() => void decide("accept")}>
{working === "accept" || working === "always" ? <><Loader2 className="h-3.5 w-3.5 animate-spin" />{working === "always" ? "Saving…" : "Approving…"}</> : "Approve & run"}
</Button>
{payload.rememberActionScope ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant={variant} className="rounded-l-none border-l border-background/30" aria-label="Approval options" disabled={!onAcceptInteraction || working !== null}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => void handleReject()}
>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Declining…
</>
) : (
"Decline"
)}
</Button>
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => void decide("always")} title={payload.rememberActionScope} aria-description={payload.rememberActionScope}>
Always allow
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<InteractionActionError message={actionError} />
</div>
</>
) : (
<ToolActionResolution
state={state}
interaction={interaction}
resolvedByLabel={resolvedByLabel}
requestedByLabel={requestedByLabel}
/>
)}
</div>
) : null}
<InteractionActionError message={actionError} />
</div>
);
}
@@ -3992,6 +3676,19 @@ export function IssueThreadInteractionCard({
creatorLabel: createdByLabel,
addresseeLabel,
});
if (isToolAction && interaction.kind === "request_confirmation" && toolActionState) {
return (
<InteractionAudienceContext.Provider value={audience}>
<RequestToolActionCard
interaction={interaction}
state={toolActionState}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}
/>
</InteractionAudienceContext.Provider>
);
}
const statusText =
adminOutcome === "withdrawn"
? "Withdrawn"
@@ -4154,16 +3851,6 @@ export function IssueThreadInteractionCard({
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
) : isToolAction && interaction.kind === "request_confirmation" && toolActionState ? (
<RequestToolActionCard
interaction={interaction}
state={toolActionState}
resolvedByLabel={resolvedByLabel}
requestedByLabel={createdByLabel}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}
/>
) : interaction.kind === "connection_intent" ? (
<ConnectionIntentInteractionBody
interaction={interaction}
+13 -10
View File
@@ -541,7 +541,7 @@ function IssueSearchInput({
}, [draftValue, onDebouncedChange]);
return (
<div className="relative w-48 sm:w-64 md:w-80">
<div className="relative w-full sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={draftValue}
@@ -1726,9 +1726,10 @@ function StreamlinedIssuesList({
{/* Toolbar */}
<IssuesToolbar
className="paperclip-task-list-toolbar"
ariaLabel={toolbarPresentation === "collection" ? "Task controls" : undefined}
context={(
<Button size="sm" variant="outline" onClick={() => openCreateIssueDialog()}>
<Button size="sm" variant="outline" aria-label={createButtonLabel} onClick={() => openCreateIssueDialog()}>
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">{createButtonLabel}</span>
</Button>
@@ -2006,7 +2007,7 @@ function StreamlinedIssuesList({
onUpdateIssue={onUpdateIssue}
/>
) : (
<>
<div className="-mx-2 sm:mx-0">
{groupedContent.map((group) => {
if (remainingRowsToRender <= 0) return null;
return (
@@ -2154,10 +2155,8 @@ function StreamlinedIssuesList({
<div
key={issue.id}
data-issue-row-id={issue.id}
// Desktop indentation comes from IssueRow's treeGuides
// (vertical connector slots); mobile keeps a plain
// padding indent (guides are sm-only).
className={depth > 0 ? MOBILE_TREE_INDENT[Math.min(depth, MOBILE_TREE_INDENT.length - 1)] : undefined}
// Canonical rows use the same tree-guide slots at every width.
className={rowPresentation === "legacy" && depth > 0 ? MOBILE_TREE_INDENT[Math.min(depth, MOBILE_TREE_INDENT.length - 1)] : undefined}
style={useDeferredRowRendering
? {
contentVisibility: "auto",
@@ -2231,8 +2230,11 @@ function StreamlinedIssuesList({
)
) : undefined}
statusSlot={rowPresentation === "task" ? (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<span className="relative inline-flex items-start self-stretch sm:items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
{hasChildren && isExpanded ? (
<span aria-hidden="true" className="pointer-events-none absolute top-5 -bottom-2.5 left-1/2 w-px bg-border sm:hidden" />
) : null}
</span>
) : undefined}
metadata={rowPresentation === "task" ? (
@@ -2286,7 +2288,8 @@ function StreamlinedIssuesList({
/>
</>
) : undefined}
mobileMeta={issueActivityText(issue).toLowerCase()}
mobileTitleMeta={rowPresentation === "task" ? issueActivityTimestamp(issue) : undefined}
mobileMeta={rowPresentation === "legacy" ? issueActivityText(issue).toLowerCase() : undefined}
trailingMeta={rowPresentation === "task"
&& visibleIssueColumnSet.has("updated")
&& availableIssueColumnSet.has("updated")
@@ -2489,7 +2492,7 @@ function StreamlinedIssuesList({
</p>
</div>
)}
</>
</div>
)}
</div>
);
+250 -163
View File
@@ -1,6 +1,20 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import {
Outlet,
useLocation,
useNavigate,
useNavigationType,
useParams,
} from "@/lib/router";
import { Sidebar } from "./Sidebar.production";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar.production";
import { CompanySettingsNav } from "./access/CompanySettingsNav";
@@ -32,7 +46,10 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { instanceSettingsApi } from "../api/instanceSettings";
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import {
resolveArchivedCompanyBounce,
shouldSyncCompanySelectionFromRoute,
} from "../lib/company-selection";
import { useOptionalToastActions } from "../context/ToastContext";
import {
applyMainContentScrollTop,
@@ -45,13 +62,25 @@ import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { pinDocumentScrollToZero } from "../lib/pin-document-scroll";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
import {
PluginSlotMount,
resolveRouteSidebarSlot,
usePluginSlots,
} from "../plugins/slots";
function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null {
return getCompanyPathSegments(pathname, companyPrefix)[0]?.toLowerCase() ?? null;
function getCompanyRouteSegment(
pathname: string,
companyPrefix: string | undefined,
): string | null {
return (
getCompanyPathSegments(pathname, companyPrefix)[0]?.toLowerCase() ?? null
);
}
function getCompanyPathSegments(pathname: string, companyPrefix: string | undefined): string[] {
function getCompanyPathSegments(
pathname: string,
companyPrefix: string | undefined,
): string[] {
if (!companyPrefix) return [];
const segments = pathname.split("/").filter(Boolean);
if (segments.length < 2) return [];
@@ -63,6 +92,7 @@ const RESERVED_APP_SUBPATHS = new Set([
"browse",
"connections",
"connect",
"chat",
"review",
"attention",
"gateways",
@@ -70,7 +100,10 @@ const RESERVED_APP_SUBPATHS = new Set([
"app",
]);
function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) {
function isSkillsStoreRoute(
pathname: string,
companyPrefix: string | undefined,
) {
const segments = pathname.split("/").filter(Boolean);
if (segments[0]?.toLowerCase() === "skills") return true;
if (!companyPrefix) return false;
@@ -103,10 +136,10 @@ export function Layout() {
selectionSource,
setSelectedCompanyId,
} = useCompany();
const {
companyPrefix,
pluginRoutePath: matchedPluginRoutePath,
} = useParams<{ companyPrefix: string; pluginRoutePath?: string }>();
const { companyPrefix, pluginRoutePath: matchedPluginRoutePath } = useParams<{
companyPrefix: string;
pluginRoutePath?: string;
}>();
const navigate = useNavigate();
const location = useLocation();
const navigationType = useNavigationType();
@@ -115,15 +148,22 @@ export function Layout() {
"/company/export",
"/company/import",
].some((settingsPath) => location.pathname.includes(settingsPath));
const companyPathSegments = getCompanyPathSegments(location.pathname, companyPrefix);
const companyPathSegments = getCompanyPathSegments(
location.pathname,
companyPrefix,
);
const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools";
const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps";
const appDetailConnectionId =
isAppsRoute && companyPathSegments[1] && !RESERVED_APP_SUBPATHS.has(companyPathSegments[1].toLowerCase())
isAppsRoute &&
companyPathSegments[1] &&
!RESERVED_APP_SUBPATHS.has(companyPathSegments[1].toLowerCase())
? companyPathSegments[1]
: null;
const appDetailApplicationId =
isAppsRoute && companyPathSegments[1]?.toLowerCase() === "app" && companyPathSegments[2]
isAppsRoute &&
companyPathSegments[1]?.toLowerCase() === "app" &&
companyPathSegments[2]
? companyPathSegments[2]
: null;
// The Skills Store renders its own secondary (category) sidebar, so the main
@@ -140,12 +180,21 @@ export function Layout() {
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
return (
companies.find(
(company) => company.issuePrefix.toUpperCase() === requestedPrefix,
) ?? null
);
}, [companies, companyPrefix]);
const hasUnknownCompanyPrefix =
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
Boolean(companyPrefix) &&
!companiesLoading &&
companies.length > 0 &&
!matchedCompany;
const pluginRoutePath = useMemo(
() => matchedPluginRoutePath?.toLowerCase() ?? getCompanyRouteSegment(location.pathname, companyPrefix),
() =>
matchedPluginRoutePath?.toLowerCase() ??
getCompanyRouteSegment(location.pathname, companyPrefix),
[companyPrefix, location.pathname, matchedPluginRoutePath],
);
const routeSidebarCompanyId = matchedCompany?.id ?? null;
@@ -175,7 +224,10 @@ export function Layout() {
) : appDetailConnectionId ? (
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
) : appDetailApplicationId ? (
<AppDetailSidebar kind="application" applicationId={appDetailApplicationId} />
<AppDetailSidebar
kind="application"
applicationId={appDetailApplicationId}
/>
) : isAppsRoute || isToolsRoute ? (
<AppsSidebar />
) : routeSidebarSlot ? (
@@ -192,15 +244,17 @@ export function Layout() {
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
const data = query.state.data as
{ devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
refetchIntervalInBackground: false,
});
const keyboardShortcutsEnabled = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
const keyboardShortcutsEnabled =
useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
// A secondary sidebar always collapses the app sidebar to its rail (still
// peek-able) — a hard invariant that overrides the user pin while the route
@@ -223,15 +277,24 @@ export function Layout() {
onboardingTriggered.current = true;
openOnboarding();
}
}, [companies, companiesLoading, openOnboarding, health?.cloud, health?.deploymentMode]);
}, [
companies,
companiesLoading,
openOnboarding,
health?.cloud,
health?.deploymentMode,
]);
useEffect(() => {
if (!companyPrefix || companiesLoading || companies.length === 0) return;
if (!matchedCompany) {
const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
?? companies[0]
?? null;
const fallback =
(selectedCompanyId
? companies.find((company) => company.id === selectedCompanyId)
: null) ??
companies[0] ??
null;
if (fallback && selectedCompanyId !== fallback.id) {
setSelectedCompanyId(fallback.id, { source: "route_sync" });
}
@@ -240,7 +303,9 @@ export function Layout() {
if (companyPrefix !== matchedCompany.issuePrefix) {
const suffix = location.pathname.replace(/^\/[^/]+/, "");
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, {
replace: true,
});
return;
}
@@ -290,12 +355,14 @@ export function Layout() {
const togglePanel = togglePanelVisible;
const openSearch = useCallback(() => {
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
bubbles: true,
cancelable: true,
}));
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
bubbles: true,
cancelable: true,
}),
);
}, []);
// Peek (hover flyout) triggers for the collapsed rail. Opening has a tiny
@@ -482,7 +549,9 @@ export function Layout() {
}
const onScroll = () => {
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
updateMobileNavVisibility(
window.scrollY || document.documentElement.scrollTop || 0,
);
};
onScroll();
@@ -544,14 +613,18 @@ export function Layout() {
previousPathname.current = location.pathname;
const isHistoryPop = navigationType === "POP";
const restoredScrollTop = isHistoryPop ? scrollMemory.current.recall(location.key) : 0;
const restoredScrollTop = isHistoryPop
? scrollMemory.current.recall(location.key)
: 0;
activeScrollKey.current = location.key;
if (isHistoryPop) {
applyMainContentScrollTop(main, restoredScrollTop);
// Cached page content can finish laying out a frame after commit; re-apply
// once it has so the restored offset isn't clamped to a shorter interim height.
const raf = requestAnimationFrame(() => applyMainContentScrollTop(main, restoredScrollTop));
const raf = requestAnimationFrame(() =>
applyMainContentScrollTop(main, restoredScrollTop),
);
return () => cancelAnimationFrame(raf);
}
@@ -563,139 +636,153 @@ export function Layout() {
return (
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
// working.
isMobile ? "min-h-dvh overflow-x-clip" : "flex h-dvh flex-col overflow-clip",
)}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-(--z-200) focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to Main Content
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
{isMobile && sidebarOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
className={cn(
"bg-background text-foreground pt-(--sz-safe-top)",
// overflow-x-clip on mobile keeps a stray wide descendant from making the
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
// computed as visible, so native body scroll + the sticky breadcrumb keep
// working.
isMobile
? "min-h-dvh overflow-x-clip"
: "flex h-dvh flex-col overflow-clip",
)}
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-(--sz-safe-top) transition-transform duration-100 ease-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="w-60 shrink-0 overflow-hidden">
{hasSecondarySidebar ? secondarySidebar : <Sidebar />}
</div>
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-(--z-200) focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to Main Content
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div
className={cn(
"min-h-0 flex-1",
isMobile ? "w-full" : "flex overflow-clip",
)}
>
{isMobile && sidebarOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
</div>
) : (
<SidebarShell
open={sidebarOpen}
collapsed={collapsed}
peeking={peeking}
resizable
onPanelMouseEnter={handlePanelPointerEnter}
onPanelMouseLeave={handlePanelPointerLeave}
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
>
<div className="flex flex-1 min-h-0">
<Sidebar />
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
/>
</SidebarShell>
)}
)}
{!isMobile && hasSecondarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : null}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<StandaloneBrowserControls mobile={isMobile} />
<BreadcrumbBar />
{isMobile && isCompanySettingsRoute ? (
<div className="border-b border-border px-4 pb-3">
<CompanySettingsNav />
</div>
) : null}
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
ref={mainContentRef}
tabIndex={-1}
// Publish the pinned-composer bottom offset to descendants
// (PAP-495): while the auto-hiding mobile nav is on screen, raise
// it to the nav height so a sticky composer clears the nav; drop
// it back to the safe-area dock when the nav hides. Desktop leaves
// the token at its :root default.
style={
isMobile
? ({
"--tc-composer-bottom": mobileNavVisible
? "var(--sz-calc-14)"
: "var(--sz-calc-8)",
} as CSSProperties)
: undefined
}
{isMobile ? (
<div
className={cn(
"flex-1 p-4 outline-none md:p-6",
// Reserve the scrollbar gutter on desktop so pages whose height
// changes (e.g. switching skill-detail tabs) don't widen/shift
// when the vertical scrollbar appears or disappears (PAP-10907).
isMobile
? "overflow-visible pb-(--sz-calc-14)"
: "overflow-auto [scrollbar-gutter:stable]",
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-(--sz-safe-top) transition-transform duration-100 ease-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
{hasUnknownCompanyPrefix ? (
<NotFoundPage
scope="invalid_company_prefix"
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
/>
) : (
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="w-60 shrink-0 overflow-hidden">
{hasSecondarySidebar ? secondarySidebar : <Sidebar />}
</div>
</div>
<SidebarAccountMenu deploymentMode={health?.deploymentMode} />
</div>
) : (
<SidebarShell
open={sidebarOpen}
collapsed={collapsed}
peeking={peeking}
resizable
onPanelMouseEnter={handlePanelPointerEnter}
onPanelMouseLeave={handlePanelPointerLeave}
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
>
<div className="flex flex-1 min-h-0">
<Sidebar />
</div>
<SidebarAccountMenu deploymentMode={health?.deploymentMode} />
</SidebarShell>
)}
{!isMobile && hasSecondarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : null}
<div
className={cn(
"flex min-w-0 flex-col",
isMobile ? "w-full" : "h-full flex-1",
)}
>
<div
className={cn(
isMobile &&
"sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
</main>
<PropertiesPanel />
>
<StandaloneBrowserControls mobile={isMobile} />
<BreadcrumbBar />
{isMobile && isCompanySettingsRoute ? (
<div className="border-b border-border px-4 pb-3">
<CompanySettingsNav />
</div>
) : null}
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
ref={mainContentRef}
tabIndex={-1}
// Publish the pinned-composer bottom offset to descendants
// (PAP-495): while the auto-hiding mobile nav is on screen, raise
// it to the nav height so a sticky composer clears the nav; drop
// it back to the safe-area dock when the nav hides. Desktop leaves
// the token at its :root default.
style={
isMobile
? ({
"--tc-composer-bottom": mobileNavVisible
? "var(--sz-calc-14)"
: "var(--sz-calc-8)",
} as CSSProperties)
: undefined
}
className={cn(
"flex-1 p-4 outline-none md:p-6",
// Reserve the scrollbar gutter on desktop so pages whose height
// changes (e.g. switching skill-detail tabs) don't widen/shift
// when the vertical scrollbar appears or disappears (PAP-10907).
isMobile
? "overflow-visible pb-(--sz-calc-14)"
: "overflow-auto [scrollbar-gutter:stable]",
)}
>
{hasUnknownCompanyPrefix ? (
<NotFoundPage
scope="invalid_company_prefix"
requestedPrefix={
companyPrefix ?? selectedCompany?.issuePrefix
}
/>
) : (
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
)}
</main>
<PropertiesPanel />
</div>
</div>
</div>
</div>
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
<CommandPalette />
<NewIssueDialog />
<NewProjectDialog />
<NewGoalDialog />
<NewAgentDialog />
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
<ToastViewport />
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
<CommandPalette />
<NewIssueDialog />
<NewProjectDialog />
<NewGoalDialog />
<NewAgentDialog />
<KeyboardShortcutsCheatsheet
open={shortcutsOpen}
onOpenChange={setShortcutsOpen}
/>
<ToastViewport />
</div>
</GeneralSettingsProvider>
);
+197 -74
View File
@@ -27,7 +27,9 @@ const mockPluginSlots = vi.hoisted(() => ({
slots: [] as Array<Record<string, unknown>>,
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockPluginSlotContexts = vi.hoisted(
() => [] as Array<Record<string, unknown>>,
);
const mockSetPeeking = vi.hoisted(() => vi.fn());
const mockSetForceCollapsed = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({
@@ -40,11 +42,18 @@ let currentPathname = "/PAP/dashboard";
vi.mock("@/lib/router", () => ({
Outlet: () => <div>Outlet content</div>,
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
useLocation: () => ({
pathname: currentPathname,
search: "",
hash: "",
state: null,
}),
useNavigate: () => mockNavigate,
useNavigationType: () => "PUSH",
useParams: () => {
const [firstSegment, secondSegment, entityId] = currentPathname.split("/").filter(Boolean);
const [firstSegment, secondSegment, entityId] = currentPathname
.split("/")
.filter(Boolean);
return {
companyPrefix: firstSegment ?? "PAP",
pluginRoutePath: secondSegment,
@@ -56,7 +65,9 @@ vi.mock("@/lib/router", () => ({
vi.mock("./Sidebar", () => ({
Sidebar: ({ contentHeaderControls }: { contentHeaderControls?: boolean }) => (
<div data-content-header-controls={String(contentHeaderControls ?? false)}>Main company nav</div>
<div data-content-header-controls={String(contentHeaderControls ?? false)}>
Main company nav
</div>
),
}));
@@ -69,11 +80,15 @@ vi.mock("./AppsSidebar", () => ({
}));
vi.mock("./AgentContextualSidebar", () => ({
AgentContextualSidebar: ({ agentRef }: { agentRef: string }) => <div>Agent sidebar {agentRef}</div>,
AgentContextualSidebar: ({ agentRef }: { agentRef: string }) => (
<div>Agent sidebar {agentRef}</div>
),
}));
vi.mock("./RoutineContextualSidebar", () => ({
RoutineContextualSidebar: ({ routineId }: { routineId: string }) => <div>Routine sidebar {routineId}</div>,
RoutineContextualSidebar: ({ routineId }: { routineId: string }) => (
<div>Routine sidebar {routineId}</div>
),
}));
vi.mock("./SkillsContextualSidebar", () => ({
@@ -81,7 +96,11 @@ vi.mock("./SkillsContextualSidebar", () => ({
}));
vi.mock("./AppConnectionSidebar", () => ({
AppDetailSidebar: (props: { kind: "connection"; connectionId: string } | { kind: "application"; applicationId: string }) => (
AppDetailSidebar: (
props:
| { kind: "connection"; connectionId: string }
| { kind: "application"; applicationId: string },
) => (
<div>
{props.kind === "connection"
? `App detail sidebar connection ${props.connectionId}`
@@ -143,7 +162,10 @@ vi.mock("./SidebarAccountMenu", () => ({
}));
vi.mock("../plugins/slots", async () => {
const actual = await vi.importActual<typeof import("../plugins/slots")>("../plugins/slots");
const actual =
await vi.importActual<typeof import("../plugins/slots")>(
"../plugins/slots",
);
return {
resolveRouteSidebarSlot: actual.resolveRouteSidebarSlot,
usePluginSlots: (params: Record<string, unknown>) => {
@@ -164,7 +186,11 @@ vi.mock("../plugins/slots", async () => {
className?: string;
}) => {
mockPluginSlotContexts.push(context);
return <div data-plugin-slot-class={className}>Plugin route sidebar: {slot.displayName}</div>;
return (
<div data-plugin-slot-class={className}>
Plugin route sidebar: {slot.displayName}
</div>
);
},
};
});
@@ -266,8 +292,14 @@ describe("Layout", () => {
container = document.createElement("div");
document.body.appendChild(container);
currentPathname = "/PAP/dashboard";
mockCompanyState.companies = [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }];
mockCompanyState.selectedCompany = { id: "company-1", issuePrefix: "PAP", name: "Paperclip" };
mockCompanyState.companies = [
{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
];
mockCompanyState.selectedCompany = {
id: "company-1",
issuePrefix: "PAP",
name: "Paperclip",
};
mockCompanyState.selectedCompanyId = "company-1";
mockHealthApi.get.mockResolvedValue({
status: "ok",
@@ -278,7 +310,9 @@ describe("Layout", () => {
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
keyboardShortcuts: false,
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: true,
});
mockPluginSlots.slots = [];
mockPluginSlotContexts.length = 0;
mockSidebarState.sidebarOpen = true;
@@ -332,7 +366,9 @@ describe("Layout", () => {
enableStreamlinedUi: true,
});
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
@@ -344,13 +380,27 @@ describe("Layout", () => {
await flushReact();
await flushReact();
expect(container.querySelector(".streamlined-task-detail-surface")).not.toBeNull();
expect(container.querySelector("#main-content")?.classList.contains("pt-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("md:pt-0")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("p-4")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("md:p-6")).toBe(true);
expect(container.querySelector("#main-content")?.classList.contains("pr-0")).toBe(false);
expect(container.querySelector("#main-content")?.classList.contains("md:pr-0")).toBe(false);
expect(
container.querySelector(".streamlined-task-detail-surface"),
).not.toBeNull();
expect(
container.querySelector("#main-content")?.classList.contains("pt-0"),
).toBe(true);
expect(
container.querySelector("#main-content")?.classList.contains("md:pt-0"),
).toBe(true);
expect(
container.querySelector("#main-content")?.classList.contains("p-4"),
).toBe(true);
expect(
container.querySelector("#main-content")?.classList.contains("md:p-6"),
).toBe(true);
expect(
container.querySelector("#main-content")?.classList.contains("pr-0"),
).toBe(false);
expect(
container.querySelector("#main-content")?.classList.contains("md:pr-0"),
).toBe(false);
await act(async () => {
root.unmount();
@@ -359,7 +409,9 @@ describe("Layout", () => {
it("collapses atomically when the pointer is still over the sidebar (no re-peek) — PAP-10676", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const renderLayout = async () => {
await act(async () => {
@@ -375,18 +427,27 @@ describe("Layout", () => {
// The SidebarShell overlay panel carries the peek mouse handlers.
const panel = () =>
[...container.querySelectorAll<HTMLElement>("div")].find(
(el) => el.className.includes("inset-y-0") && el.className.includes("overflow-hidden"),
(el) =>
el.className.includes("inset-y-0") &&
el.className.includes("overflow-hidden"),
);
const hover = (el: HTMLElement) => {
// React derives onMouseEnter from a mouseover crossing in from outside.
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body }));
el.dispatchEvent(
new MouseEvent("mouseover", {
bubbles: true,
relatedTarget: document.body,
}),
);
};
// Expanded, then hover the panel so the pointer is registered as inside.
await renderLayout();
const expandedPanel = panel();
expect(expandedPanel).toBeTruthy();
await act(async () => { hover(expandedPanel!); });
await act(async () => {
hover(expandedPanel!);
});
// Collapse while the pointer is still over the panel.
mockSidebarState.collapsed = true;
@@ -397,17 +458,25 @@ describe("Layout", () => {
// A lingering/spurious hover while collapsed must NOT re-open the peek.
mockSetPeeking.mockClear();
const railPanel = panel();
await act(async () => { hover(railPanel!); });
await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
await act(async () => {
hover(railPanel!);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 80));
});
expect(mockSetPeeking).not.toHaveBeenCalledWith(true);
await act(async () => { root.unmount(); });
await act(async () => {
root.unmount();
});
});
it("opens the peek when hovering a collapsed rail (positive control for the hover sim)", async () => {
mockSidebarState.collapsed = true;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -418,17 +487,28 @@ describe("Layout", () => {
await flushReact();
const panel = [...container.querySelectorAll<HTMLElement>("div")].find(
(el) => el.className.includes("inset-y-0") && el.className.includes("overflow-hidden"),
(el) =>
el.className.includes("inset-y-0") &&
el.className.includes("overflow-hidden"),
);
expect(panel).toBeTruthy();
await act(async () => {
panel!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body }));
panel!.dispatchEvent(
new MouseEvent("mouseover", {
bubbles: true,
relatedTarget: document.body,
}),
);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 80));
});
await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
// A normal collapsed-rail hover (not just-collapsed) opens the peek.
expect(mockSetPeeking).toHaveBeenCalledWith(true);
await act(async () => { root.unmount(); });
await act(async () => {
root.unmount();
});
});
it("replaces the app sidebar with settings navigation on Streamlined settings routes", async () => {
@@ -493,7 +573,9 @@ describe("Layout", () => {
enableStreamlinedUi: false,
});
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
@@ -638,7 +720,9 @@ describe("Layout", () => {
it("mounts the Apps secondary sidebar regardless of the retired experimental flag", async () => {
currentPathname = "/PAP/apps";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: false,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -717,34 +801,40 @@ describe("Layout", () => {
// Reserved Apps subroutes are not connection ids. They must keep the
// top-level Apps sidebar, never mount a detail sidebar for a phantom app.
it.each(["browse", "connections", "vercel-connect", "review"])("keeps the Apps sidebar on the %s surface", async (route) => {
currentPathname = `/PAP/apps/${route}`;
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
it.each(["browse", "connections", "vercel-connect", "review", "chat"])(
"keeps the Apps sidebar on the %s surface",
async (route) => {
currentPathname = `/PAP/apps/${route}`;
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Apps sidebar");
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("App detail sidebar");
expect(container.textContent).toContain("Apps sidebar");
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("App detail sidebar");
await act(async () => {
root.unmount();
});
});
await act(async () => {
root.unmount();
});
},
);
it("keeps the Apps sidebar on the gateways list and detail routes", async () => {
for (const pathname of ["/PAP/apps/gateways", "/PAP/apps/gateways/gw-1/overview"]) {
for (const pathname of [
"/PAP/apps/gateways",
"/PAP/apps/gateways/gw-1/overview",
]) {
currentPathname = pathname;
const root = createRoot(container);
const queryClient = new QueryClient({
@@ -788,7 +878,9 @@ describe("Layout", () => {
await flushReact();
await flushReact();
expect(container.textContent).toContain("App detail sidebar connection conn-1");
expect(container.textContent).toContain(
"App detail sidebar connection conn-1",
);
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Apps sidebar");
@@ -814,7 +906,9 @@ describe("Layout", () => {
await flushReact();
await flushReact();
expect(container.textContent).toContain("App detail sidebar application app-1");
expect(container.textContent).toContain(
"App detail sidebar application app-1",
);
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Apps sidebar");
@@ -846,7 +940,10 @@ describe("Layout", () => {
for (const [pathname, sidebarText] of [
["/PAP/skills/studio", "Skills sidebar"],
["/PAP/agents/briefing-analyst/skills", "Agent sidebar briefing-analyst"],
["/PAP/agents/briefing-analyst/runs/run-1", "Agent sidebar briefing-analyst"],
[
"/PAP/agents/briefing-analyst/runs/run-1",
"Agent sidebar briefing-analyst",
],
["/PAP/routines/routine-1/overview", "Routine sidebar routine-1"],
] as const) {
const root = await renderAt(pathname);
@@ -855,12 +952,14 @@ describe("Layout", () => {
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
expect(secondaryRail?.classList.contains("w-60")).toBe(true);
expect(secondaryRail?.classList.contains("bg-background")).toBe(true);
const breadcrumb = Array.from(container.querySelectorAll("div"))
.find((element) => element.textContent === "Breadcrumbs");
const breadcrumb = Array.from(container.querySelectorAll("div")).find(
(element) => element.textContent === "Breadcrumbs",
);
expect(breadcrumb).toBeDefined();
expect(
breadcrumb && secondaryRail
? breadcrumb.compareDocumentPosition(secondaryRail) & Node.DOCUMENT_POSITION_FOLLOWING
? breadcrumb.compareDocumentPosition(secondaryRail) &
Node.DOCUMENT_POSITION_FOLLOWING
: 0,
).not.toBe(0);
await act(async () => {
@@ -870,7 +969,7 @@ describe("Layout", () => {
}
});
it("keeps global navigation on legacy Agent and Routine detail routes", async () => {
it("keeps global navigation with agent configuration navigation in the legacy shell", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableApps: true,
enableStreamlinedUi: false,
@@ -882,7 +981,9 @@ describe("Layout", () => {
]) {
currentPathname = pathname;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
@@ -895,7 +996,8 @@ describe("Layout", () => {
await flushReact();
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Agent sidebar");
if (pathname.includes("/agents/")) expect(container.textContent).toContain("Agent sidebar");
else expect(container.textContent).not.toContain("Agent sidebar");
expect(container.textContent).not.toContain("Routine sidebar");
await act(async () => {
@@ -912,7 +1014,9 @@ describe("Layout", () => {
enableStreamlinedUi: false,
});
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
@@ -934,10 +1038,16 @@ describe("Layout", () => {
});
it("keeps Agent and Routine collection routes in global navigation", async () => {
for (const pathname of ["/PAP/agents/all", "/PAP/agents/new", "/PAP/routines"]) {
for (const pathname of [
"/PAP/agents/all",
"/PAP/agents/new",
"/PAP/routines",
]) {
currentPathname = pathname;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
@@ -1001,8 +1111,12 @@ describe("Layout", () => {
await flushReact();
await flushReact();
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
expect(container.querySelector("[data-plugin-slot-class='min-h-0 flex-1']")).not.toBeNull();
expect(container.textContent).toContain(
"Plugin route sidebar: Wiki Sidebar",
);
expect(
container.querySelector("[data-plugin-slot-class='min-h-0 flex-1']"),
).not.toBeNull();
expect(container.textContent).not.toContain("Main company nav");
expect(container.textContent).not.toContain("Company settings sidebar");
expect(container.textContent).not.toContain("Instance sidebar");
@@ -1059,7 +1173,9 @@ describe("Layout", () => {
enabled: true,
}),
);
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
expect(container.textContent).toContain(
"Plugin route sidebar: Wiki Sidebar",
);
expect(container.textContent).not.toContain("Main company nav");
await act(async () => {
@@ -1073,7 +1189,11 @@ describe("Layout", () => {
{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
{ id: "company-2", issuePrefix: "ALT", name: "Alternate" },
];
mockCompanyState.selectedCompany = { id: "company-1", issuePrefix: "PAP", name: "Paperclip" };
mockCompanyState.selectedCompany = {
id: "company-1",
issuePrefix: "PAP",
name: "Paperclip",
};
mockCompanyState.selectedCompanyId = "company-1";
mockPluginSlots.slots = [
{
@@ -1194,7 +1314,10 @@ describe("Layout", () => {
});
});
async function renderLayoutRoot(): Promise<{ root: ReturnType<typeof createRoot>; rootEl: HTMLElement }> {
async function renderLayoutRoot(): Promise<{
root: ReturnType<typeof createRoot>;
rootEl: HTMLElement;
}> {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
+7 -4
View File
@@ -66,6 +66,7 @@ const RESERVED_APP_SUBPATHS = new Set([
"browse",
"connections",
"connect",
"chat",
"vercel-connect",
"review",
"attention",
@@ -200,7 +201,7 @@ export function Layout() {
/>
)
) : null;
const secondarySidebar = streamlinedUiEnabled && shellRoute.builtInContextualSurface === "agent" && agentId ? (
const secondarySidebar = shellRoute.builtInContextualSurface === "agent" && agentId ? (
<AgentContextualSidebar agentRef={agentId} />
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
<RoutineContextualSidebar routineId={routineId} />
@@ -234,7 +235,7 @@ export function Layout() {
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
refetchIntervalInBackground: false,
});
const keyboardShortcutsEnabled = useQuery({
queryKey: queryKeys.instance.generalSettings,
@@ -738,7 +739,7 @@ export function Layout() {
? ({
"--tc-composer-bottom": mobileNavVisible
? "var(--sz-calc-14)"
: "var(--sz-calc-8)",
: "var(--tc-composer-hidden-nav-offset)",
} as CSSProperties)
: undefined
}
@@ -752,7 +753,9 @@ export function Layout() {
// changes (e.g. switching skill-detail tabs) don't widen/shift
// when the vertical scrollbar appears or disappears (PAP-10907).
isMobile
? "overflow-visible pb-(--sz-calc-14)"
? isTaskDetailRoute && !mobileNavVisible
? "overflow-visible pb-(--tc-composer-hidden-nav-offset)"
: "overflow-visible pb-(--sz-calc-14)"
: "overflow-auto [scrollbar-gutter:stable]",
)}
>
@@ -0,0 +1,39 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ProjectWorkspace } from "@paperclipai/shared";
import { Link } from "lucide-react";
import { projectsApi } from "@/api/projects";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
/** Preserve manual URL editing for workspaces created before the GitHub picker. */
export function LegacyProjectRepository({ workspace, projectRef }: { workspace: ProjectWorkspace; projectRef: string }) {
const client = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
const save = useMutation({
mutationFn: async () => {
const repoUrl = draft?.trim() || null;
if (!repoUrl && !workspace.cwd && !workspace.remoteWorkspaceRef) return projectsApi.removeWorkspace(workspace.projectId, workspace.id);
return projectsApi.updateWorkspace(workspace.projectId, workspace.id, { repoUrl });
},
onSuccess: () => {
void client.invalidateQueries({ queryKey: queryKeys.projects.all(workspace.companyId) });
void client.invalidateQueries({ queryKey: queryKeys.projects.detail(workspace.projectId) });
void client.invalidateQueries({ queryKey: queryKeys.projects.detail(projectRef) });
setDraft(null);
},
});
return <div className="flex min-w-0 flex-col gap-2">
<span className="text-xs text-muted-foreground">Existing repo URL</span>
{draft === null ? <div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2">
<Link className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 break-all text-sm">{workspace.repoUrl}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setDraft(workspace.repoUrl ?? "")}>Edit</Button>
</div> : <form className="flex flex-col gap-2" onSubmit={(event) => { event.preventDefault(); if (!save.isPending) save.mutate(); }}>
<Input aria-label="Existing repo URL" type="url" value={draft} disabled={save.isPending} onChange={(event) => setDraft(event.target.value)} />
{save.isError && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
<div className="flex justify-end gap-2"><Button type="button" variant="ghost" disabled={save.isPending} onClick={() => setDraft(null)}>Cancel</Button><Button type="submit" disabled={save.isPending}>Save URL</Button></div>
</form>}
</div>;
}
+16
View File
@@ -14,6 +14,7 @@ import {
type MarkdownEditorRef,
type MentionOption,
placeCaretAfterMentionAnchor,
placeCaretAtEditableEnd,
shouldAcceptAutocompleteKey,
} from "./MarkdownEditor";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
@@ -1192,6 +1193,21 @@ describe("MarkdownEditor", () => {
editable.remove();
});
it("places the caret after a plain action command", () => {
const editable = document.createElement("div");
editable.contentEditable = "true";
editable.textContent = "/goal\u00a0";
document.body.appendChild(editable);
expect(placeCaretAtEditableEnd(editable)).toBe(true);
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(editable);
expect(selection?.anchorOffset).toBe(1);
editable.remove();
});
function createTouchEvent(
type: "touchstart" | "touchmove" | "touchend",
touches: Array<{ clientX: number; clientY: number }>,
+54 -5
View File
@@ -44,7 +44,7 @@ import {
buildRoutineMentionHref,
buildUserMentionHref,
} from "@paperclipai/shared";
import { Boxes, CalendarClock, Hash, User, X } from "lucide-react";
import { Boxes, CalendarClock, Flag, Hash, User, X } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
@@ -93,6 +93,8 @@ interface MarkdownEditorProps {
bordered?: boolean;
/** List of mentionable entities. Enables @-mention autocomplete. */
mentions?: MentionOption[];
/** Capability-aware action commands supplied by the owning composer. */
actionCommands?: SlashCommandOption[];
/** Called on Cmd/Ctrl+Enter */
onSubmit?: () => void;
/** Render the rich editor without allowing edits. */
@@ -102,6 +104,7 @@ interface MarkdownEditorProps {
export interface MarkdownEditorRef {
focus: () => void;
insertMarkdown: (markdown: string) => void;
clear: () => void;
}
class MarkdownEditorRichErrorBoundary extends Component<
@@ -548,10 +551,16 @@ function mentionMarkdown(option: MentionOption): string {
}
function slashCommandLabel(option: SlashCommandOption): string {
return option.kind === "routine" ? `/routine:${option.name}` : `/${option.slug}`;
if (option.kind === "routine") return `/routine:${option.name}`;
if (option.kind === "action") return `/${option.command}`;
return `/${option.slug}`;
}
function slashCommandMarkdown(option: SlashCommandOption): string {
// MDXEditor trims an ordinary trailing space when setMarkdown re-imports the
// command. Keep a non-breaking separator in the document so subsequent text
// is inserted as the command argument instead of being glued to the token.
if (option.kind === "action") return `/${option.command}\u00a0`;
if (option.kind === "routine") {
return `[${slashCommandLabel(option)}](${buildRoutineMentionHref(option.routineId)}) `;
}
@@ -559,7 +568,7 @@ function slashCommandMarkdown(option: SlashCommandOption): string {
}
function autocompleteMarkdown(option: AutocompleteOption): string {
return option.kind === "skill" || option.kind === "routine"
return option.kind === "skill" || option.kind === "routine" || option.kind === "action"
? slashCommandMarkdown(option)
: mentionMarkdown(option);
}
@@ -588,6 +597,7 @@ export function isSameAutocompleteSession(
}
function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string): boolean {
if (option.kind === "action") return false;
const parsed = parseMentionChipHref(href);
if (!parsed) return false;
@@ -669,6 +679,17 @@ export function placeCaretAfterMentionAnchor(target: HTMLAnchorElement): boolean
return true;
}
export function placeCaretAtEditableEnd(target: HTMLElement): boolean {
const selection = window.getSelection();
if (!selection) return false;
const range = document.createRange();
range.selectNodeContents(target);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
/** Replace the active autocomplete token in the markdown string with the selected token. */
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
const search = `${state.marker}${state.query}`;
@@ -692,11 +713,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
fileDropTarget = "editor",
bordered = true,
mentions,
actionCommands = [],
onSubmit,
readOnly = false,
}: MarkdownEditorProps, forwardedRef) {
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
const { slashCommands } = useEditorAutocomplete();
const { slashCommands: sharedSlashCommands } = useEditorAutocomplete();
const slashCommands = useMemo(
() => [...actionCommands, ...sharedSlashCommands],
[actionCommands, sharedSlashCommands],
);
const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<MDXEditorMethods>(null);
const fallbackTextareaRef = useRef<HTMLTextAreaElement>(null);
@@ -815,6 +841,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
},
insertMarkdown,
clear: () => {
latestValueRef.current = "";
echoIgnoreMarkdownRef.current = "";
ref.current?.setMarkdown("");
if (fallbackTextareaRef.current) fallbackTextareaRef.current.value = "";
},
}), [insertMarkdown, richEditorError]);
const autoSizeFallbackTextarea = useCallback((element: HTMLTextAreaElement | null) => {
@@ -1136,6 +1168,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// update state between the last render and this callback firing).
const state = mentionStateRef.current;
if (!state) return false;
if (option.kind === "action" && option.disabled) return false;
const current = latestValueRef.current;
const next = applyMention(current, state, option);
if (next !== current) {
@@ -1154,6 +1187,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
decorateProjectMentions();
editable.focus();
if (option.kind === "action") {
placeCaretAtEditableEnd(editable);
return;
}
const target = findClosestAutocompleteAnchor(editable, option, state);
if (!target) {
if (attemptsRemaining > 0) {
@@ -1514,12 +1552,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
key={option.id}
type="button"
tabIndex={-1}
disabled={option.kind === "action" && option.disabled === true}
aria-disabled={option.kind === "action" && option.disabled === true}
title={option.kind === "action" && option.disabled ? option.disabledReason ?? undefined : undefined}
ref={(node) => {
autocompleteOptionRefs.current[i] = node;
}}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
option.kind === "action" && option.disabled && "cursor-not-allowed opacity-60 hover:bg-transparent",
)}
onPointerDown={(e) => {
// Touch is handled via onTouchStart/onTouchEnd so vertical scrolling
@@ -1540,6 +1582,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
>
{option.kind === "routine" ? (
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "action" ? (
<Flag className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "issue" ? (
@@ -1566,7 +1610,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
</span>
) : (
<span className="truncate">
{option.kind === "skill" || option.kind === "routine"
{option.kind === "skill" || option.kind === "routine" || option.kind === "action"
? slashCommandLabel(option)
: option.name}
</span>
@@ -1596,6 +1640,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
Routine
</span>
)}
{option.kind === "action" && (
<span className="ml-auto max-w-28 truncate text-(length:--text-nano) text-muted-foreground">
{option.disabled ? option.disabledReason : option.description}
</span>
)}
</button>
))}
</div>,
+4 -4
View File
@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { NavLink, useLocation } from "@/lib/router";
import {
House,
CircleDot,
CircleCheck,
SquarePen,
Users,
Inbox,
@@ -44,8 +44,8 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
const items = useMemo<MobileNavItem[]>(
() => [
{ type: "link", to: "/dashboard", label: "Home", icon: House },
{ type: "link", to: "/issues", label: "Tasks", icon: CircleDot },
{ type: "action", label: "Create", icon: SquarePen, onClick: () => openNewIssue() },
{ type: "link", to: "/issues", label: "Tasks", icon: CircleCheck },
{ type: "action", label: "New Task", icon: SquarePen, onClick: () => openNewIssue() },
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
{
type: "link",
@@ -61,7 +61,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
return (
<nav
className={cn(
"fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-transform duration-200 ease-out md:hidden pb-(--sz-safe-bottom)",
"fixed bottom-0 left-0 right-0 z-30 bg-border/50 transition-transform duration-200 ease-out dark:bg-muted md:hidden pb-(--sz-safe-bottom)",
visible ? "translate-y-0" : "translate-y-full",
)}
aria-label="Mobile navigation"
+180 -271
View File
@@ -1,283 +1,192 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { NewAgentDialog } from "./NewAgentDialog";
const createCompanyInviteMock = vi.hoisted(() => vi.fn());
const getInviteOnboardingMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const listAdaptersMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
const closeNewAgentMock = vi.hoisted(() => vi.fn());
const openNewIssueMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
const clipboardWriteTextMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
import { queryKeys } from "@/lib/queryKeys";
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: {
getExperimental: async () => ({ enableNativeRunner: true }),
},
}));
const invites = vi.hoisted(() => ({ createCompanyInvite: vi.fn(), getInviteOnboarding: vi.fn(), copy: vi.fn() }));
vi.mock("../api/access", () => ({ accessApi: invites }));
vi.mock("../lib/clipboard", () => ({ copyTextToClipboard: invites.copy }));
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) }));
const state = vi.hoisted(() => ({
adapters: [] as object[],
navigate: vi.fn(),
close: vi.fn(),
}));
vi.mock("@/lib/router", () => ({ useNavigate: () => state.navigate }));
vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
newAgentOpen: true,
closeNewAgent: closeNewAgentMock,
openNewIssue: openNewIssueMock,
}),
useDialog: () => ({ newAgentOpen: true, closeNewAgent: state.close }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
vi.mock("@/api/adapters", () => ({
adaptersApi: { list: async () => state.adapters },
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
}));
vi.mock("../api/access", () => ({
accessApi: {
createCompanyInvite: (companyId: string, input: unknown) =>
createCompanyInviteMock(companyId, input),
getInviteOnboarding: (token: string) => getInviteOnboardingMock(token),
},
}));
vi.mock("../api/agents", () => ({
agentsApi: {
list: (companyId: string) => listAgentsMock(companyId),
},
}));
vi.mock("../api/adapters", () => ({
adaptersApi: {
list: () => listAdaptersMock(),
},
}));
vi.mock("../adapters", () => ({
listUIAdapters: () => [
{ type: "claude_local" },
{ type: "paperclip_runner" },
{ type: "openclaw_gateway" },
],
}));
vi.mock("../adapters/metadata", () => ({
isVisualAdapterChoice: (type: string) => type !== "openclaw_gateway",
}));
vi.mock("../adapters/use-disabled-adapters", () => ({
useDisabledAdaptersSync: () => new Set<string>(),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mock("./onboarding/PillGuy", () => ({ PillGuy: () => null }));
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
let root: Root;
let container: HTMLDivElement;
let cache: QueryClient;
async function click(label: string) {
const button = [...document.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === label,
);
expect(button).toBeTruthy();
await act(async () => button!.click());
}
describe("NewAgentDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
listAgentsMock.mockResolvedValue([
{ id: "agent-ceo", role: "ceo" },
]);
listAdaptersMock.mockResolvedValue([]);
createCompanyInviteMock.mockResolvedValue({
id: "invite-1",
token: "agent-token",
inviteUrl: "https://paperclip.local/invite/agent-token",
expiresAt: "2026-04-20T00:00:00.000Z",
allowedJoinTypes: "agent",
humanRole: null,
onboardingTextUrl: "https://paperclip.local/api/invites/agent-token/onboarding.txt",
onboardingTextPath: "/api/invites/agent-token/onboarding.txt",
});
getInviteOnboardingMock.mockResolvedValue({
onboarding: {
connectivity: {
connectionCandidates: ["https://paperclip.local"],
testResolutionEndpoint: {
url: "https://paperclip.local/api/invites/agent-token/test-resolution",
},
},
},
});
Object.defineProperty(globalThis.navigator, "clipboard", {
configurable: true,
value: { writeText: clipboardWriteTextMock },
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("generates an external agent onboarding prompt inside the add-agent modal", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<NewAgentDialog />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Add a new agent");
expect(container.textContent).toContain("Invite an external agent");
const inviteButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.startsWith("Invite an external agent"),
);
await act(async () => {
inviteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Generate a one-time onboarding prompt");
expect(container.textContent).not.toContain("Organization Invites");
const generateButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Generate onboarding prompt",
);
await act(async () => {
generateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(createCompanyInviteMock).toHaveBeenCalledWith("company-1", {
allowedJoinTypes: "agent",
humanRole: null,
agentMessage: null,
});
expect(getInviteOnboardingMock).toHaveBeenCalledWith("agent-token");
expect(clipboardWriteTextMock).toHaveBeenCalledWith(
expect.stringContaining("You're invited to join a Paperclip company as an agent."),
);
expect(container.textContent).toContain("Agent onboarding prompt");
expect(container.textContent).toContain("Send this prompt to the external agent");
expect(container.textContent).not.toContain("Optional message for the agent");
expect(container.textContent).not.toContain("Generate onboarding prompt");
expect(pushToastMock).toHaveBeenCalledWith({
title: "Agent invite created",
body: "Agent onboarding prompt ready below and copied to clipboard.",
tone: "success",
});
const backButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Back",
);
await act(async () => {
backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Optional message for the agent");
expect(container.textContent).toContain("Generate onboarding prompt");
await act(async () => {
root.unmount();
});
});
it("hides Paperclip Runner configuration until the server enables it", async () => {
listAdaptersMock.mockResolvedValue([
{ type: "claude_local", disabled: false },
{ type: "paperclip_runner", disabled: true },
]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<NewAgentDialog />
</QueryClientProvider>,
);
});
await flushReact();
const configureButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Configure a runtime manually"),
);
await act(async () => {
configureButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Claude Code");
expect(container.textContent).not.toContain("Paperclip Runner");
await act(async () => {
root.unmount();
});
});
it("shows Paperclip Runner configuration after the server enables it", async () => {
listAdaptersMock.mockResolvedValue([
{ type: "claude_local", disabled: false },
{ type: "paperclip_runner", disabled: false },
]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<NewAgentDialog />
</QueryClientProvider>,
);
});
await flushReact();
const configureButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Configure a runtime manually"),
);
await act(async () => {
configureButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Paperclip Runner");
await act(async () => {
root.unmount();
});
beforeEach(async () => {
vi.clearAllMocks();
invites.createCompanyInvite.mockResolvedValue({ token: "one-time-token", onboardingTextPath: "/api/invites/one-time-token/onboarding.txt" });
invites.getInviteOnboarding.mockResolvedValue({ onboarding: { connectivity: {} } });
invites.copy.mockResolvedValue(undefined);
state.adapters = [
{ type: "codex_local", loaded: true },
{ type: "paperclip_runner", loaded: true },
{ type: "claude_local", loaded: true, disabled: true },
];
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
cache = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () =>
root.render(
<QueryClientProvider client={cache}>
<NewAgentDialog />
</QueryClientProvider>,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
});
afterEach(async () => {
await act(async () => root.unmount());
cache.clear();
container.remove();
});
async function name() {
const input = document.querySelector("input")!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!.call(input, "Ada & Co");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await click("Choose adapter");
}
it("requires a name and an enabled adapter before navigating", async () => {
await click("Choose adapter");
expect(document.body.textContent).toContain("Agent name");
await name();
expect(document.querySelector('input[value="claude_local"]')).toBeNull();
await click("Configure agent");
expect(state.navigate).not.toHaveBeenCalled();
await act(async () =>
(
document.querySelector('input[value="codex_local"]') as HTMLInputElement
).click(),
);
await click("Configure agent");
expect(state.close).toHaveBeenCalledTimes(1);
const query = new URL(state.navigate.mock.calls[0][0], "http://local")
.searchParams;
expect(query.get("name")).toBe("Ada & Co");
expect(query.get("adapterType")).toBe("codex_local");
});
it("offers native Codex, Claude ACPX, and OpenCode runners", async () => {
await name();
await act(async () =>
(
document.querySelector(
'input[value="paperclip_runner"]',
) as HTMLInputElement
).click(),
);
const options = [...document.querySelectorAll("option")].map(
(option) => option.textContent,
);
expect(options).toContain("Codex (app server)");
expect(options).toContain("Claude (ACPX)");
expect(options).toContain("OpenCode");
expect(options.join(" ")).not.toContain("ACPX Codex");
});
it.each([false, undefined])(
"hides the runner unless explicitly enabled (%s)",
async (enableNativeRunner) => {
await act(async () => {
cache.setQueryData(queryKeys.instance.experimentalSettings, {
enableNativeRunner,
});
});
await name();
expect(
document.querySelector('input[value="paperclip_runner"]'),
).toBeNull();
expect(document.querySelector('input[value="codex_local"]')).not.toBeNull();
},
);
it("offers only Claude, Codex, and OpenCode on Cloud, even with the runner enabled", async () => {
await act(async () => {
cache.setQueryData(queryKeys.health, {
status: "ok",
cloud: { managed: true },
});
cache.setQueryData(
queryKeys.adapters.all,
[
"claude_local",
"codex_local",
"opencode_local",
"cursor",
"cursor_cloud",
"gemini_local",
"grok_local",
"kimi_local",
"pi_local",
"hermes_local",
"paperclip_runner",
].map((type) => ({ type, loaded: true })),
);
});
await name();
expect(
[...document.querySelectorAll<HTMLInputElement>('input[type="radio"]')].map(
(input) => input.value,
),
).toEqual(["claude_local", "codex_local", "opencode_local"]);
expect(document.body.textContent).not.toContain("CLI harness");
});
it("keeps agent-only invitations reachable from the new-agent flow", async () => {
await click("Invite an external agent");
const message = document.querySelector("textarea")!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(message, "Help with research");
message.dispatchEvent(new Event("input", { bubbles: true }));
});
await click("Generate onboarding prompt");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(invites.createCompanyInvite).toHaveBeenCalledWith("company-1", {
allowedJoinTypes: "agent", humanRole: null, agentMessage: "Help with research",
});
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
expect(invites.copy).toHaveBeenCalled();
expect(state.navigate).not.toHaveBeenCalled();
});
it("keeps the generated invitation readable when clipboard access fails", async () => {
invites.copy.mockRejectedValue(new Error("Clipboard unavailable"));
invites.getInviteOnboarding.mockRejectedValue(new Error("Manifest unavailable"));
await click("Invite an external agent");
await click("Generate onboarding prompt");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(document.body.textContent).toContain("Copy the prompt manually");
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
});
+20 -411
View File
@@ -1,421 +1,30 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useCompany } from "../context/CompanyContext";
import { ExternalAgentInviteDialog } from "./new-agent/ExternalAgentInviteDialog";
import { useNavigate } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { adaptersApi } from "../api/adapters";
import { queryKeys } from "@/lib/queryKeys";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowLeft,
Bot,
Check,
MailPlus,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
import { listUIAdapters } from "../adapters";
import { isVisualAdapterChoice } from "../adapters/metadata";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { useToast } from "../context/ToastContext";
import { Badge } from "@/components/ui/badge";
/**
* Adapter types that are suitable for agent creation (excludes internal
* system adapters like "process" and "http").
*/
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
type NewAgentDialogMode = "choices" | "runtime" | "invite" | "prompt";
function isAgentAdapterType(type: string): boolean {
return !SYSTEM_ADAPTER_TYPES.has(type);
}
import { AgentBasicsDialog } from "./new-agent/AgentBasicsDialog";
export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
const { newAgentOpen, closeNewAgent } = useDialog();
const { selectedCompanyId } = useCompany();
const { pushToast } = useToast();
if (!newAgentOpen) return null;
return <NewAgentDialogContent key={selectedCompanyId} companyId={selectedCompanyId} onClose={closeNewAgent} />;
}
function NewAgentDialogContent({ companyId, onClose }: { companyId: string | null; onClose: () => void }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [mode, setMode] = useState<NewAgentDialogMode>("choices");
const [agentMessage, setAgentMessage] = useState("");
const [latestAgentPrompt, setLatestAgentPrompt] = useState<string | null>(null);
const [latestAgentPromptCopied, setLatestAgentPromptCopied] = useState(false);
const disabledTypes = useDisabledAdaptersSync();
function resetDialogState() {
setMode("choices");
setAgentMessage("");
setLatestAgentPrompt(null);
setLatestAgentPromptCopied(false);
}
useEffect(() => {
if (!latestAgentPromptCopied) return;
const timeout = window.setTimeout(() => {
setLatestAgentPromptCopied(false);
}, 1600);
return () => window.clearTimeout(timeout);
}, [latestAgentPromptCopied]);
// Fetch registered adapters from server (syncs disabled store + provides data)
const { data: serverAdapters } = useQuery({
queryKey: queryKeys.adapters.all,
queryFn: () => adaptersApi.list(),
staleTime: 5 * 60 * 1000,
});
const nativeRunnerAvailable =
serverAdapters?.some(
(adapter) => adapter.type === "paperclip_runner" && !adapter.disabled,
) === true;
// Fetch existing agents for the "Ask CEO" flow
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newAgentOpen,
});
const ceoAgent = (agents ?? []).find((a) => a.role === "ceo");
const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", 5);
// Build the adapter grid from the UI registry merged with display metadata.
// This automatically includes external/plugin adapters.
const adapterGrid = useMemo(() => {
const registered = listUIAdapters()
.filter((a) =>
isAgentAdapterType(a.type) &&
(a.type !== "paperclip_runner" || nativeRunnerAvailable) &&
!disabledTypes.has(a.type) &&
isVisualAdapterChoice(a.type)
);
// Sort: recommended first, then alphabetical
return registered
.map((a) => {
const display = getAdapterDisplay(a.type);
return {
value: a.type,
label: display.label,
desc: display.description,
icon: display.icon,
recommended: display.recommended,
comingSoon: display.comingSoon,
disabledLabel: display.disabledLabel,
};
})
.sort((a, b) => {
if (a.recommended && !b.recommended) return -1;
if (!a.recommended && b.recommended) return 1;
return a.label.localeCompare(b.label);
});
}, [disabledTypes, nativeRunnerAvailable, serverAdapters]);
function handleAskCeo() {
closeNewAgent();
openNewIssue({
assigneeAgentId: ceoAgent?.id,
title: "Create a new agent",
description: "(type in what kind of agent you want here)",
});
}
function handleAdvancedConfig() {
setMode("runtime");
}
function handleInviteExternalAgent() {
setMode("invite");
}
function handleAdvancedAdapterPick(adapterType: string) {
closeNewAgent();
resetDialogState();
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
}
async function copyText(text: string, unavailableBody: string) {
try {
await copyTextToClipboard(text);
return true;
} catch {
pushToast({
title: "Clipboard unavailable",
body: unavailableBody,
tone: "warn",
});
return false;
}
}
const createAgentInviteMutation = useMutation({
mutationFn: () =>
accessApi.createCompanyInvite(selectedCompanyId!, {
allowedJoinTypes: "agent",
humanRole: null,
agentMessage: agentMessage.trim() || null,
}),
onSuccess: async (invite) => {
const base = window.location.origin.replace(/\/+$/, "");
const onboardingTextLink =
invite.onboardingTextUrl ??
invite.onboardingTextPath ??
`/api/invites/${invite.token}/onboarding.txt`;
const onboardingTextUrl = onboardingTextLink.startsWith("http")
? onboardingTextLink
: `${base}${onboardingTextLink}`;
let prompt: string;
try {
const manifest = await accessApi.getInviteOnboarding(invite.token);
prompt = buildAgentOnboardingPrompt({
onboardingTextUrl,
connectionCandidates:
manifest.onboarding.connectivity?.connectionCandidates ?? null,
testResolutionUrl:
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
null,
});
} catch {
prompt = buildAgentOnboardingPrompt({
onboardingTextUrl,
connectionCandidates: null,
testResolutionUrl: null,
});
}
setLatestAgentPrompt(prompt);
setLatestAgentPromptCopied(false);
setMode("prompt");
const copied = await copyText(prompt, "Copy the agent onboarding prompt manually from the field below.");
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
pushToast({
title: "Agent invite created",
body: copied ? "Agent onboarding prompt ready below and copied to clipboard." : "Agent onboarding prompt ready below.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to create agent invite",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const [invite, setInvite] = useState(false);
if (invite && companyId) return <ExternalAgentInviteDialog companyId={companyId} onClose={onClose} onBack={() => setInvite(false)} />;
return (
<Dialog
open={newAgentOpen}
onOpenChange={(open) => {
if (!open) {
resetDialogState();
closeNewAgent();
}
<AgentBasicsDialog
open
onClose={onClose}
onInvite={companyId ? () => setInvite(true) : undefined}
onContinue={(basics) => {
onClose();
navigate(`/agents/new?${new URLSearchParams(basics)}`);
}}
>
<DialogContent
showCloseButton={false}
className={cn(
"max-h-(--sz-calc-16) p-0 gap-0 overflow-hidden flex flex-col",
mode === "invite" || mode === "prompt" ? "sm:max-w-2xl" : "sm:max-w-md",
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<span className="text-sm text-muted-foreground">Add a new agent</span>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => {
resetDialogState();
closeNewAgent();
}}
>
<span className="text-lg leading-none">&times;</span>
</Button>
</div>
<div className="min-h-0 overflow-y-auto p-6 space-y-6">
{mode === "choices" ? (
<>
{/* Recommendation */}
<div className="text-center space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
<Bot className="h-6 w-6 text-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Ask a leader to propose the hire, configure a runtime yourself,
or send an onboarding prompt to an external agent.
</p>
</div>
<Button className="w-full" size="lg" onClick={handleAskCeo}>
<Bot className="h-4 w-4 mr-2" />
Ask the CEO to create a new agent
</Button>
<div className="grid gap-2">
<Button variant="outline" className="w-full" onClick={handleAdvancedConfig}>
<Settings2 className="h-4 w-4 mr-2" />
Configure a runtime manually
</Button>
<div className="space-y-1">
<Button variant="outline" className="w-full" onClick={handleInviteExternalAgent}>
<MailPlus className="h-4 w-4 mr-2" />
Invite an external agent
</Button>
<p className="text-xs text-muted-foreground text-center">
(OpenClaw, Hermes, or any agent that can call the invite API.)
</p>
</div>
</div>
</>
) : mode === "runtime" ? (
<>
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMode("choices")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<p className="text-sm text-muted-foreground">
Choose the runtime Paperclip should start or resume directly.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{adapterGrid.map((opt) => (
<button
key={opt.value}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative",
opt.comingSoon && "opacity-40 cursor-not-allowed",
)}
disabled={!!opt.comingSoon}
title={opt.comingSoon ? opt.disabledLabel : undefined}
onClick={() => {
if (!opt.comingSoon) handleAdvancedAdapterPick(opt.value);
}}
>
{opt.recommended && (
<Badge variant="ghost" className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-(length:--text-nano) font-semibold px-1.5 leading-none">
Recommended
</Badge>
)}
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-(length:--text-nano)">
{opt.desc}
</span>
</button>
))}
</div>
</>
) : mode === "invite" ? (
<div className="space-y-5">
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMode("choices")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<div className="space-y-1">
<h2 className="text-sm font-semibold">Invite an external agent</h2>
<p className="text-sm text-muted-foreground">
Generate a one-time onboarding prompt that any compatible agent can use to request access, wait for approval, and claim its Paperclip API key.
</p>
</div>
</div>
<label className="block space-y-2">
<span className="text-sm font-medium">Optional message for the agent</span>
<Textarea
value={agentMessage}
onChange={(event) => setAgentMessage(event.target.value)}
className="min-h-24 resize-y"
placeholder="Add onboarding context, expected role, or first instructions."
maxLength={4000}
/>
</label>
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
Agent invites create a join request first. An organization admin still approves the request before the agent can claim its API key.
</div>
<div>
<Button
onClick={() => createAgentInviteMutation.mutate()}
disabled={!selectedCompanyId || createAgentInviteMutation.isPending}
>
{createAgentInviteMutation.isPending ? "Generating…" : "Generate onboarding prompt"}
</Button>
</div>
</div>
) : (
<div className="space-y-5">
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMode("invite")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">Agent onboarding prompt</h2>
{latestAgentPromptCopied ? (
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
<Check className="h-3.5 w-3.5" />
Copied
</div>
) : null}
</div>
<p className="text-sm text-muted-foreground">
Send this prompt to the external agent that should join this organization.
</p>
</div>
</div>
<Textarea
readOnly
value={latestAgentPrompt ?? ""}
className="h-(--sz-24rem) resize-y font-mono text-xs"
/>
<Button
size="sm"
variant="outline"
disabled={!latestAgentPrompt}
onClick={async () => {
if (!latestAgentPrompt) return;
const copied = await copyText(latestAgentPrompt, "Copy the agent onboarding prompt manually from the field above.");
setLatestAgentPromptCopied(copied);
}}
>
{latestAgentPromptCopied ? "Copied prompt" : "Copy prompt"}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
/>
);
}
+29 -2
View File
@@ -186,11 +186,13 @@ vi.mock("./InlineEntitySelector", async () => {
{
value: string;
placeholder?: string;
className?: string;
triggerDataSlot?: string;
renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode;
}
>(function InlineEntitySelectorMock({ value, placeholder, renderTriggerValue }, ref) {
>(function InlineEntitySelectorMock({ value, placeholder, className, triggerDataSlot, renderTriggerValue }, ref) {
return (
<button ref={ref} type="button">
<button ref={ref} type="button" className={className} data-slot={triggerDataSlot}>
{(renderTriggerValue?.(value ? { id: value, label: value } : null) ?? value) || placeholder}
</button>
);
@@ -412,6 +414,31 @@ describe("NewIssueDialog", () => {
act(() => rerendered.root.unmount());
});
it("uses the compact composer control proportions for mobile task fields", async () => {
const { root } = renderDialog(container);
await flush();
const compactControls = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="new-issue-compact-control"]'),
);
const prefix = compactControls.find((control) => control.textContent === "PAP");
const assignee = compactControls.find((control) => control.textContent === "Assignee");
const project = compactControls.find((control) => control.textContent === "Project");
const status = compactControls.find((control) => control.textContent?.trim() === "Todo");
const upload = compactControls.find((control) => control.textContent?.trim() === "Upload");
const mode = compactControls.find((control) => control.hasAttribute("data-issue-work-mode-chip"));
const more = container.querySelector<HTMLElement>('[data-testid="new-issue-more-menu-trigger"]');
expect(prefix?.className).toContain("p-1.5");
for (const control of [assignee, project, status, upload, mode]) {
expect(control?.className).toContain("h-8");
expect(control?.className).toContain("px-2.5");
}
expect(more?.className).toContain("size-8");
act(() => root.unmount());
});
it("submits parent and goal context for sub-issues", async () => {
mockProjectsApi.list.mockResolvedValue([
{
+20 -7
View File
@@ -1,3 +1,4 @@
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { AgentEnvConfig, EnvBinding, IssueWorkMode } from "@paperclipai/shared";
@@ -603,12 +604,13 @@ export function NewIssueDialog() {
});
}, [agents, companyMembers?.users, orderedProjects]);
const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(selectedAssigneeAgent?.adapterConfig ?? {}).provider ?? "codex") : undefined;
const { data: assigneeAdapterModels } = useQuery({
queryKey:
effectiveCompanyId && assigneeAdapterType
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType)
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType, null, catalogProvider)
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!),
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!, { provider: catalogProvider }),
enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides,
});
@@ -1388,7 +1390,8 @@ export function NewIssueDialog() {
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<PopoverTrigger asChild>
<button
className="px-1.5 py-0.5 rounded bg-muted text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity"
data-slot="new-issue-compact-control"
className="rounded bg-muted p-1.5 text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity sm:px-1.5 sm:py-0.5"
disabled={isSubIssueMode}
>
{dialogCompany?.issuePrefix ?? ""}
@@ -1476,6 +1479,8 @@ export function NewIssueDialog() {
options={assigneeOptions}
recentOptionIds={recentAssigneeOptionIds}
placeholder="Assignee"
className="h-8 px-2.5 py-0 sm:h-auto sm:px-2 sm:py-1"
triggerDataSlot="new-issue-compact-control"
disablePortal
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
@@ -1535,6 +1540,8 @@ export function NewIssueDialog() {
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
className="h-8 px-2.5 py-0 sm:h-auto sm:px-2 sm:py-1"
triggerDataSlot="new-issue-compact-control"
disablePortal
noneLabel="No project"
searchPlaceholder="Search projects..."
@@ -2077,7 +2084,10 @@ export function NewIssueDialog() {
{/* Status chip */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<button
data-slot="new-issue-compact-control"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 py-0 text-xs hover:bg-accent/50 transition-colors sm:h-auto sm:px-2 sm:py-1"
>
<CircleDot className={cn("h-3 w-3", currentStatus.color)} />
{currentStatus.label}
</button>
@@ -2159,7 +2169,8 @@ export function NewIssueDialog() {
multiple
/>
<button
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
data-slot="new-issue-compact-control"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 py-0 text-xs hover:bg-accent/50 transition-colors text-muted-foreground sm:h-auto sm:px-2 sm:py-1"
onClick={() => stageFileInputRef.current?.click()}
disabled={createIssue.isPending}
>
@@ -2173,9 +2184,10 @@ export function NewIssueDialog() {
<button
type="button"
data-issue-work-mode-chip={workMode}
data-slot="new-issue-compact-control"
aria-keyshortcuts="Meta+Period Control+Period"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
"inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 py-0 text-xs transition-colors sm:h-auto sm:px-2 sm:py-1",
currentWorkMode.classes.chip,
)}
>
@@ -2215,7 +2227,8 @@ export function NewIssueDialog() {
<button
type="button"
data-testid="new-issue-more-menu-trigger"
className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50"
data-slot="new-issue-compact-control"
className="inline-flex size-8 items-center justify-center rounded-md border border-border p-0 text-xs text-muted-foreground transition-colors hover:bg-accent/50 sm:size-auto sm:p-1"
>
<MoreHorizontal className="h-3 w-3" />
</button>
@@ -16,7 +16,10 @@ function act(callback: () => void) {
});
}
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn(), createWorkspace: vi.fn() } }));
vi.mock("../api/projects", () => ({ projectsApi: {
create: vi.fn(),
repositoryOptions: vi.fn().mockResolvedValue({ repositories: [], connectionCount: 0, failedConnectionCount: 0 }),
} }));
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/agents", () => ({ agentsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/access", () => ({ accessApi: { listUserDirectory: vi.fn().mockResolvedValue({ users: [] }) } }));
@@ -80,38 +83,23 @@ function localPathInput() {
return document.body.querySelector('input[placeholder="/absolute/path/to/workspace"]');
}
describe("NewProjectDialog — local folder under the managed-sandbox-only policy", () => {
it("offers the local folder field and its picker when the policy is off", () => {
render({});
expect(documentText()).toContain("Local folder");
expect(localPathInput()).not.toBeNull();
const chooseButtons = Array.from(document.body.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Choose",
);
expect(chooseButtons.length).toBeGreaterThan(0);
});
it("keeps the local folder field hidden while the policy is still loading", () => {
// A cold cache resolves the policy to false on the first render. The guard
// fails closed so a managed instance never flashes the field.
render(null);
expect(documentText()).not.toContain("Local folder");
expect(localPathInput()).toBeNull();
expect(documentText()).toContain("Repo URL");
});
it("hides the local folder field and its picker when the policy is on", () => {
render({ enableManagedSandboxOnly: true });
describe("NewProjectDialog — source repositories across host policies", () => {
it.each([
["policy off", {}],
["policy unresolved", null],
["managed sandbox only", { enableManagedSandboxOnly: true }],
] as const)("offers the simplified project form with %s", (_label, settings) => {
render(settings);
expect(documentText()).toContain("Source repos");
expect(documentText()).toContain("Add GitHub repo");
expect(documentText()).not.toContain("Repo URL");
expect(documentText()).not.toContain("Local folder");
expect(localPathInput()).toBeNull();
expect(document.body.querySelector('input[placeholder="Project name"]')).not.toBeNull();
const chooseButtons = Array.from(document.body.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Choose",
);
expect(chooseButtons).toHaveLength(0);
// The repo field is unrelated to the host filesystem, so it stays.
expect(documentText()).toContain("Repo URL");
});
});
+51 -441
View File
@@ -1,457 +1,67 @@
import { useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ProjectRepository } from "@paperclipai/shared";
import { Folder, X } from "lucide-react";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { accessApi } from "../api/access";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { goalsApi } from "../api/goals";
import { assetsApi } from "../api/assets";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Maximize2,
Minimize2,
Target,
Calendar,
Plus,
X,
HelpCircle,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { StatusBadge } from "./StatusBadge";
import { ChoosePathButton } from "./PathInstructionsModal";
import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly";
const projectStatuses = [
{ value: "backlog", label: "Backlog" },
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
];
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import { Button } from "./ui/button";
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
export function NewProjectDialog() {
const { newProjectOpen, closeNewProject } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { hideHostPaths } = useManagedSandboxOnly();
const { selectedCompanyId } = useCompany();
return selectedCompanyId && newProjectOpen
? <NewProjectForm key={selectedCompanyId} companyId={selectedCompanyId} onClose={closeNewProject} /> : null;
}
export function NewProjectForm({ companyId, onClose }: { companyId: string; onClose: () => void }) {
const client = useQueryClient();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("planned");
const [goalIds, setGoalIds] = useState<string[]>([]);
const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false);
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [goalOpen, setGoalOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const { data: goals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const mentionOptions = useMemo<MentionOption[]>(() => {
return buildMarkdownMentionOptions({
agents,
members: companyMembers?.users,
});
}, [agents, companyMembers?.users]);
const createProject = useMutation({
mutationFn: (data: Record<string, unknown>) =>
projectsApi.create(selectedCompanyId!, data),
});
const uploadDescriptionImage = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No organization selected");
return assetsApi.uploadImage(selectedCompanyId, file, "projects/drafts");
const [repos, setRepos] = useState<ProjectRepository[]>([]);
const [connecting, setConnecting] = useState(false);
const input = useRef<HTMLInputElement>(null);
const create = useMutation({
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
onSuccess: () => {
void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
onClose();
},
});
function reset() {
setName("");
setDescription("");
setStatus("planned");
setGoalIds([]);
setTargetDate("");
setExpanded(false);
setWorkspaceLocalPath("");
setWorkspaceRepoUrl("");
setWorkspaceError(null);
}
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const looksLikeRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
if (parsed.protocol !== "https:") return false;
const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2;
} catch {
return false;
}
};
const deriveWorkspaceNameFromPath = (value: string) => {
const normalized = value.trim().replace(/[\\/]+$/, "");
const segments = normalized.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? "Local folder";
};
const deriveWorkspaceNameFromRepo = (value: string) => {
try {
const parsed = new URL(value);
const segments = parsed.pathname.split("/").filter(Boolean);
const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? "";
return repo || "GitHub repo";
} catch {
return "GitHub repo";
}
};
async function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
const localPath = workspaceLocalPath.trim();
const repoUrl = workspaceRepoUrl.trim();
if (localPath && !isAbsolutePath(localPath)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
if (repoUrl && !looksLikeRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL.");
return;
}
setWorkspaceError(null);
try {
const created = await createProject.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
status,
// No color is sent — new projects persist color = null (neutral gray). See PAP-68.
...(goalIds.length > 0 ? { goalIds } : {}),
...(targetDate ? { targetDate } : {}),
});
if (localPath || repoUrl) {
const workspacePayload: Record<string, unknown> = {
name: localPath
? deriveWorkspaceNameFromPath(localPath)
: deriveWorkspaceNameFromRepo(repoUrl),
...(localPath ? { cwd: localPath } : {}),
...(repoUrl ? { repoUrl } : {}),
};
await projectsApi.createWorkspace(created.id, workspacePayload);
}
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(created.id) });
reset();
closeNewProject();
} catch {
// surface through createProject.isError
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
const selectedGoals = (goals ?? []).filter((g) => goalIds.includes(g.id));
const availableGoals = (goals ?? []).filter((g) => !goalIds.includes(g.id));
return (
<Dialog
open={newProjectOpen}
onOpenChange={(open) => {
if (!open) {
reset();
closeNewProject();
}
}}
>
<DialogContent
showCloseButton={false}
className={cn("p-0 gap-0", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCompany && (
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
{selectedCompany.issuePrefix}
</span>
)}
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New project</span>
return <Dialog open onOpenChange={(open) => { if (!open && !create.isPending) onClose(); }}>
<DialogContent showCloseButton={false} aria-describedby={undefined}
className="flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 shadow-sm sm:max-w-lg"
onOpenAutoFocus={(event) => { event.preventDefault(); input.current?.focus(); }}>
{connecting ? <div className="min-h-0 overflow-y-auto p-5">
<DialogTitle className="sr-only">Connect GitHub</DialogTitle>
<ConnectionSetupFlow host="dialog" serviceSlug="github" forceNewConnection onCancel={() => setConnecting(false)} onComplete={() => {
void client.invalidateQueries({ queryKey: repositoryOptionsKey(companyId) });
setConnecting(false);
}} />
</div> : <form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim() && !create.isPending) create.mutate(); }}>
<div className="flex shrink-0 flex-col gap-4 px-5 pb-4 pt-5">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-lg font-semibold">Create project</DialogTitle>
<Button type="button" variant="ghost" size="icon-sm" disabled={create.isPending} aria-label="Close new project" onClick={onClose}><X className="size-4" /></Button>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => { reset(); closeNewProject(); }}
>
<span className="text-lg leading-none">&times;</span>
</Button>
<div className="flex items-center gap-3 rounded-lg border border-input px-3 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring">
<Folder className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input ref={input} aria-label="Project name" value={name} disabled={create.isPending} onChange={(event) => setName(event.target.value)} placeholder="Project name" required
className="h-10 w-full min-w-0 border-0 bg-transparent text-base outline-none placeholder:text-muted-foreground md:text-sm" />
</div>
</div>
{/* Name */}
<div className="px-4 pt-4 pb-2 shrink-0">
<input
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
descriptionEditorRef.current?.focus();
}
}}
autoFocus
/>
<div role="region" aria-label="Source repositories" tabIndex={0} className="min-h-0 overflow-y-auto overscroll-contain px-5 pb-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring">
<ProjectRepositoryInput companyId={companyId} selected={repos} onChange={setRepos} onConnect={() => setConnecting(true)} disabled={create.isPending} />
</div>
{/* Description */}
<div className="px-4 pb-2">
<MarkdownEditor
ref={descriptionEditorRef}
value={description}
onChange={setDescription}
placeholder="Add description..."
bordered={false}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-(--sz-220px)" : "min-h-(--sz-120px)")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;
}}
/>
{create.isError && <p role="alert" className="px-5 pt-3 text-sm text-destructive">{create.error.message}</p>}
<div className="flex shrink-0 justify-end gap-2 px-5 py-5">
<Button type="button" variant="ghost" disabled={create.isPending} onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={!name.trim() || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
</div>
<div className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Repo URL</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-(--sz-240px) text-xs">
Link a GitHub repository so agents can clone, read, and push code for this project.
</TooltipContent>
</Tooltip>
</div>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
placeholder="https://github.com/org/repo"
/>
</div>
{/*
The local folder is an absolute path on the execution host. Under
the managed-sandbox-only policy every agent runs in the
platform-managed environment, so the field and its folder picker
never render and the create request carries no cwd. The field also
stays hidden until the policy is known.
*/}
{!hideHostPaths && (
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Local folder</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-(--sz-240px) text-xs">
Set an absolute path on this machine where local agents will read and write files for this project.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
</div>
{/* Property chips */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<StatusBadge status={status} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{projectStatuses.map((s) => (
<button
key={s.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
s.value === status && "bg-accent"
)}
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
>
{s.label}
</button>
))}
</PopoverContent>
</Popover>
{selectedGoals.map((goal) => (
<span
key={goal.id}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
>
<Target className="h-3 w-3 text-muted-foreground" />
<span className="max-w-(--sz-160px) truncate">{goal.title}</span>
<button
className="text-muted-foreground hover:text-foreground"
onClick={() => setGoalIds((prev) => prev.filter((id) => id !== goal.id))}
aria-label={`Remove goal ${goal.title}`}
type="button"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
<PopoverTrigger asChild>
<button
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors disabled:opacity-60"
disabled={selectedGoals.length > 0 && availableGoals.length === 0}
>
{selectedGoals.length > 0 ? <Plus className="h-3 w-3 text-muted-foreground" /> : <Target className="h-3 w-3 text-muted-foreground" />}
{selectedGoals.length > 0 ? "+ Goal" : "Goal"}
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="start">
{selectedGoals.length === 0 && (
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => setGoalOpen(false)}
>
No goal
</button>
)}
{availableGoals.map((g) => (
<button
key={g.id}
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate"
onClick={() => {
setGoalIds((prev) => [...prev, g.id]);
setGoalOpen(false);
}}
>
{g.title}
</button>
))}
{selectedGoals.length > 0 && availableGoals.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
All goals already selected.
</div>
)}
</PopoverContent>
</Popover>
{/* Target date */}
<div className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
<Calendar className="h-3 w-3 text-muted-foreground" />
<input
type="date"
className="bg-transparent outline-none text-xs w-24"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
placeholder="Target date"
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
{createProject.isError ? (
<p className="text-xs text-destructive">Failed to create project.</p>
) : (
<span />
)}
<Button
size="sm"
disabled={!name.trim() || createProject.isPending}
onClick={handleSubmit}
>
{createProject.isPending ? "Creating…" : "Create project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
</form>}
</DialogContent>
</Dialog>;
}
@@ -96,7 +96,6 @@ vi.mock("../adapters/use-adapter-capabilities", () => ({
}));
// Animation / canvas-ish children that add nothing to the logic under test.
vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null }));
vi.mock("./FrontDoor", () => ({ FrontDoor: () => null }));
vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null }));
import { queryKeys } from "../lib/queryKeys";
+61 -464
View File
@@ -6,10 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
import {
ONBOARDING_AGENT_STEP,
ONBOARDING_MISSION_STEP,
} from "../lib/onboarding-route";
import { ONBOARDING_AGENT_STEP } from "../lib/onboarding-route";
/**
* Which step the onboarding wizard *lands on*, and what is allowed to move it
@@ -20,8 +17,8 @@ import {
* guards lived in that seam rather than in either side of it — the pure
* helpers in `onboarding-route.test.ts` passed while the wizard was moving a
* customer off the step they were typing on. So the real component is rendered
* here, with the real route resolver and the real mission hook, and only the
* network and the surrounding contexts are stubbed.
* here, with the real route resolver, and only the network and the surrounding
* contexts are stubbed.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -36,6 +33,7 @@ const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({
create: vi.fn(),
adapterModels: vi.fn(),
list: vi.fn(),
hire: vi.fn(),
instructionsBundle: vi.fn(),
saveInstructionsFile: vi.fn(),
@@ -97,34 +95,19 @@ vi.mock("../context/CompanyContext", () => ({
// Canvas/animation leaves — nothing to do with the step machinery.
vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null }));
vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null }));
vi.mock("./FrontDoor", () => ({ FrontDoor: () => null }));
const { OnboardingWizard } = await import("./OnboardingWizard");
/** The mission step renders this heading; the agent step renders this input. */
function currentStep(): "mission" | "agent" | "closed" | "other" {
/** The agent step renders this input; the org-name step ("other") does not. */
function currentStep(): "agent" | "closed" | "other" {
const body = document.body;
if (!body.querySelector("[role='dialog'], .fixed.inset-0")) return "closed";
const headings = [...body.querySelectorAll("h3")].map((h) => h.textContent);
if (headings.includes("Define your mission")) return "mission";
// Keyed on the name field, which is the agent step's only control now that
// the role picker is gone.
if (body.querySelector("#onboarding-agent-name")) return "agent";
return "other";
}
function confirmMissionButton(): HTMLButtonElement | null {
return (
[...document.body.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Confirm mission"),
) ?? null
);
}
function missionTextarea(): HTMLTextAreaElement | null {
return document.body.querySelector("textarea");
}
/** Type into a controlled React input without a full user-event dependency. */
function setControlledValue(el: HTMLTextAreaElement | HTMLInputElement, value: string) {
const prototype =
@@ -198,6 +181,9 @@ describe("OnboardingWizard — which step it lands on", () => {
mockAdaptersApi.list.mockResolvedValue([]);
mockGoalsApi.list.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
// The hire step lists the company's agents first so it can adopt one that
// already carries the typed name instead of hiring a duplicate.
mockAgentsApi.list.mockResolvedValue([]);
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
mockAgentsApi.instructionsBundle.mockResolvedValue({ entryFile: "AGENTS.md" });
mockAgentsApi.saveInstructionsFile.mockResolvedValue({});
@@ -338,24 +324,25 @@ describe("OnboardingWizard — which step it lands on", () => {
it("does not move an open wizard when the dialog is re-opened with a new step", async () => {
// The dashboard's auto-open sits behind queries too, so a refetch can call
// `openOnboarding` again with a different step for the same company. The
// wizard belongs to the customer by then.
// wizard belongs to the customer by then, so the sync effect keys on the
// company: the same company re-deciding a fresher step must not move them.
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_MISSION_STEP,
};
await render();
await settle();
expect(currentStep()).toBe("mission");
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_AGENT_STEP,
};
await render();
await settle();
expect(currentStep()).toBe("agent");
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: 5,
};
await rerender();
await settle();
expect(currentStep()).toBe("mission");
expect(currentStep()).toBe("agent");
});
it("re-decides the company when the route names a different one", async () => {
@@ -373,368 +360,42 @@ describe("OnboardingWizard — which step it lands on", () => {
expect(currentStep()).toBe("agent");
});
describe("the mission step, reached with a company that already exists", () => {
// Nothing sent an existing company here until the dashboard started
// opening agentless ones on this step. Both defects below were reachable
// the moment it did.
it("withdraws a company the wizard created once the route stops naming it", async () => {
// The route only introduces a company when it names one the wizard is not
// already holding, so a company the wizard *created* was never recorded as
// route-owned and was never withdrawn. Visiting its own onboarding path and
// then `/onboarding` left the wizard showing "create an organization" while
// still holding it — and the next confirmation wrote into the old company.
mockCompaniesApi.create.mockResolvedValue({ id: "company-1", issuePrefix: "PC1" });
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" });
routerState.pathname = "/onboarding";
await render();
await settle();
async function openOnMissionStepForExistingCompany() {
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_MISSION_STEP,
};
await render();
await settle();
expect(currentStep()).toBe("mission");
}
// The route no longer lands on the mission step — onboarding stopped
// asking — so a test that needs that step opens it the way the tenant app
// will when it collects the mission later: explicitly, naming the company.
// What these tests defend is unchanged: state written for one company must
// not survive into the next.
async function openMissionStepFor(companyId: string) {
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId,
initialStep: ONBOARDING_MISSION_STEP,
};
await render();
await settle();
expect(currentStep()).toBe("mission");
}
async function click(el: Element) {
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
it("names the company it is asking about, so the step can be completed", async () => {
// `companyName` is only ever typed on step 1. Without a backfill it is
// empty here, the step's own copy has a blank where the name goes, and
// "Confirm mission" stays disabled — a customer sent to this step could
// not leave it.
await openOnMissionStepForExistingCompany();
expect(document.body.textContent).toContain("Acme");
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
expect(confirmMissionButton()?.disabled).toBe(false);
const nameInput = document.body.querySelector("input")! as HTMLInputElement;
setControlledValue(nameInput, "Acme");
await settle();
await act(async () => {
[...document.body.querySelectorAll("button")]
.find((b) => b.textContent?.trim() === "Continue")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
await settle();
expect(mockCompaniesApi.create).toHaveBeenCalled();
expect(currentStep()).toBe("agent");
it("saves the mission it asked for", async () => {
// Confirming used to advance to the agent step and write nothing, so the
// company kept no mission — the exact state this change exists to remove.
mockGoalsApi.create.mockResolvedValue({ id: "goal-new" });
await openOnMissionStepForExistingCompany();
// Its own onboarding path, then back to the unprefixed one.
routerState.pathname = "/PC1/onboarding";
await rerender();
await settle();
routerState.pathname = "/onboarding";
await rerender();
await settle();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ title: "Ship the thing", level: "company", status: "active" }),
);
expect(currentStep()).toBe("agent");
});
it("does not write a second mission when Enter is pressed twice", async () => {
// The buttons are all disabled while a request is in flight; the
// keyboard has to be too. A second Enter re-enters the handler before
// the first has set the goal id its own guard reads, so both requests
// see "no mission yet" and the company ends up with two.
let resolveCreate: (goal: { id: string }) => void = () => {};
mockGoalsApi.create.mockReturnValue(
new Promise<{ id: string }>((resolve) => {
resolveCreate = resolve;
}),
);
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
const surface = document.body.querySelector(".fixed.inset-0.z-50.flex")!;
const submit = () =>
act(async () => {
surface.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }),
);
});
await submit();
await submit();
await act(async () => resolveCreate({ id: "goal-new" }));
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledTimes(1);
});
it("updates the mission it could not see, rather than adding a second", async () => {
// The cost of failing open. The lookup could not answer, so the customer
// was asked for a mission the company already had. Adding a goal would
// leave two active company-level goals, and the earlier one would keep
// winning `selectDefaultCompanyGoalId` outside this wizard — so the
// mission the customer just typed would lose. Their answer wins instead.
// The dashboard's lookup failed, which is why this company is on the
// mission step at all. By the time the customer confirms, the goal list
// reads — and it has a mission.
mockGoalsApi.list.mockResolvedValue([COMPANY_GOAL]);
mockGoalsApi.update.mockResolvedValue({ id: COMPANY_GOAL.id });
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "The mission they just typed");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).not.toHaveBeenCalled();
expect(mockGoalsApi.update).toHaveBeenCalledWith(
COMPANY_GOAL.id,
expect.objectContaining({ title: "The mission they just typed" }),
);
expect(currentStep()).toBe("agent");
});
it("still writes the mission when the pre-write read also fails", async () => {
// Fail-open all the way down. If it cannot tell whether a mission
// exists, an unwritten mission is the worse error.
mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable"));
mockGoalsApi.create.mockResolvedValue({ id: "goal-new" });
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ title: "Ship the thing" }),
);
expect(currentStep()).toBe("agent");
});
it("does not carry a mission across a switch to another company", async () => {
// Confirming for one company sets the goal id that `handleConfirmMission`
// reads as "already written". Carried across a company switch it makes
// the next company skip saving its own mission, and the launch path then
// links that company's project to the previous company's goal.
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" });
await openMissionStepFor("company-1");
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Acme's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(currentStep()).toBe("agent");
dialogState.onboardingOptions = {
companyId: "company-2",
initialStep: ONBOARDING_MISSION_STEP,
};
await rerender();
await settle();
expect(currentStep()).toBe("mission");
const direct2 = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct2);
setControlledValue(missionTextarea()!, "Globex's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledTimes(2);
expect(mockGoalsApi.create).toHaveBeenLastCalledWith(
"company-2",
expect.objectContaining({ title: "Globex's mission" }),
);
});
it("does not hand a new company the mission written for the old one", async () => {
// A route change can switch companies while the write is in flight, and
// the switch clears exactly the state the write is about to set. The
// goal is written and correct either way — but attributing it to the
// company now in hand would undo the clearing and let that company skip
// its own mission.
let resolveCreate: (goal: { id: string }) => void = () => {};
mockGoalsApi.create.mockReturnValue(
new Promise<{ id: string }>((resolve) => {
resolveCreate = resolve;
}),
);
await openMissionStepFor("company-1");
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Acme's mission");
await settle();
await click(confirmMissionButton()!);
// Switch companies before the write lands, then let it land.
dialogState.onboardingOptions = {
companyId: "company-2",
initialStep: ONBOARDING_MISSION_STEP,
};
await rerender();
await settle();
await act(async () => resolveCreate({ id: "goal-company-1" }));
await settle();
// Globex must still be asked, and must write its own mission.
expect(currentStep()).toBe("mission");
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-2" });
const direct2 = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct2);
setControlledValue(missionTextarea()!, "Globex's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenLastCalledWith(
"company-2",
expect.objectContaining({ title: "Globex's mission" }),
);
});
it("does not carry a mission through a route that withdraws the company", async () => {
// Withdrawing a company and replacing one are the same event: this
// company is no longer the wizard's. Clearing only on replacement leaves
// a goal id behind, and the company created next would read it as
// "mission already written" and never be asked for one.
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" });
// Reached explicitly: the route no longer lands here. The withdrawal this
// defends against is still route-driven, so the route is set too — it takes
// over the moment the explicit open is released.
routerState.pathname = "/PC1/onboarding";
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_MISSION_STEP,
};
await render();
await settle();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Acme's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(currentStep()).toBe("agent");
// Navigate to the unprefixed route, which names no company.
routerState.pathname = "/onboarding";
await rerender();
await settle();
// The wizard is back at company creation with nothing carried over.
const nameInput = document.body.querySelector("input") as HTMLInputElement | null;
expect(nameInput?.value).toBe("");
expect(document.body.textContent).not.toContain("Acme's mission");
});
it("withdraws a company the wizard created once the route stops naming it", async () => {
// The route only introduces a company when it names one the wizard is
// not already holding, so a company the wizard *created* was never
// recorded as route-owned and was never withdrawn. Visiting its own
// onboarding path and then `/onboarding` left the wizard showing
// "create a company" while still holding it — and the next confirmation
// wrote that customer's new mission into the old company.
mockCompaniesApi.create.mockResolvedValue({ id: "company-1", issuePrefix: "PC1" });
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" });
routerState.pathname = "/onboarding";
await render();
await settle();
const nameInput = document.body.querySelector("input")! as HTMLInputElement;
setControlledValue(nameInput, "Acme");
await settle();
await click(
[...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Continue",
)!,
);
await settle();
await settle();
expect(mockCompaniesApi.create).toHaveBeenCalled();
expect(currentStep()).toBe("agent");
// Its own onboarding path, then back to the unprefixed one.
routerState.pathname = "/PC1/onboarding";
await rerender();
await settle();
routerState.pathname = "/onboarding";
await rerender();
await settle();
const nameAfter = document.body.querySelector("input") as HTMLInputElement | null;
expect(nameAfter?.value).toBe("");
expect(document.body.textContent).not.toContain("Acme's mission");
});
it("does not write a second mission when the step is confirmed twice", async () => {
mockGoalsApi.create.mockResolvedValue({ id: "goal-new" });
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
await click(confirmMissionButton()!);
await settle();
// Back to the mission step, then forward again.
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Back"),
)!;
await click(back);
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledTimes(1);
expect(currentStep()).toBe("agent");
});
// Back at the organization-name step with nothing carried over.
const nameAfter = document.body.querySelector("input") as HTMLInputElement | null;
expect(nameAfter?.value).toBe("");
});
it("does not adopt a company it created once a route has supplied one", async () => {
@@ -831,15 +492,17 @@ describe("OnboardingWizard — which step it lands on", () => {
it("applies the step again when the wizard is re-opened", async () => {
// Same guard, from the other side: closing and re-opening is a new
// request, so a freeze that outlived the open would be its own defect.
// request, so a freeze that outlived the open would be its own defect. It
// opens on step 1, closes, then re-opens on the agent step — the re-open
// has to apply the fresh step rather than stay where the first one left it.
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_MISSION_STEP,
initialStep: 1,
};
await render();
await settle();
expect(currentStep()).toBe("mission");
expect(currentStep()).toBe("other");
dialogState.onboardingOpen = false;
await rerender();
@@ -856,9 +519,9 @@ describe("OnboardingWizard — which step it lands on", () => {
expect(currentStep()).toBe("agent");
});
describe("a company that already has its mission", () => {
// It opens on the agent step, so steps 1 and 2 never run. Everything the
// mission feeds has to come from the company instead of the form.
describe("an existing company opened on the agent step", () => {
// It opens on the agent step, so step 1 never runs. The wizard hires the
// first agent there — the mission it once seeded from is now the server's.
const MISSION_GOAL = {
...COMPANY_GOAL,
@@ -883,9 +546,7 @@ describe("OnboardingWizard — which step it lands on", () => {
const field = document.getElementById("onboarding-agent-name") as HTMLInputElement;
expect(field, "the agent step should render its name field").toBeTruthy();
setControlledValue(field, name);
// Settle twice: the hire is guarded on the company's goal lookup
// (`missionUnresolvedForHire`), and a Connect that fires before that
// query resolves is swallowed by the guard rather than failing loudly.
// Settle twice so the connect step's queries resolve before the press.
await settle();
await settle();
}
@@ -926,70 +587,6 @@ describe("OnboardingWizard — which step it lands on", () => {
await press(tiles[0]!);
}
it("seeds the lead agent's instructions with the mission it was never asked for", async () => {
// The regression this exists for. The agent step feeds
// `composeCeoInstructions` from the mission field, and a company entered
// here never types one — so the agent was hired knowing nothing of the
// mission the customer gave at signup, and nothing reported it.
await openOnAgentStep();
await nameAgent();
await press(stepCta());
await pickModelSource();
expect(stepCta().hasAttribute("disabled")).toBe(false);
await press(stepCta());
expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalled();
const [, file] = mockAgentsApi.saveInstructionsFile.mock.calls[0];
expect(file.content).toContain("Scale the marketplace");
expect(file.content).toContain("Reach 1000 sellers");
});
it("will not hire while the mission is being re-read", async () => {
// Cached goals plus an in-flight refetch: the field holds the right
// company's mission, but not necessarily its current one. Hiring inside
// that window seeds the agent from a value about to change, and reports
// nothing — the same "retained data is not an answer" rule the draft
// ownership gate follows.
await openOnAgentStep();
await nameAgent();
await press(stepCta());
await pickModelSource();
expect(stepCta().hasAttribute("disabled")).toBe(false);
mockGoalsApi.list.mockReturnValue(new Promise(() => {}));
await act(async () => {
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list("company-1"),
});
});
await settle(2);
expect(stepCta().hasAttribute("disabled")).toBe(true);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
});
// Removed: "hydrates again when the same company comes back through
// onboarding".
//
// It closed the wizard with the X and re-opened it, which made `reset()`
// clear `hydratedMissionForRef` and the second pass hydrate again. The arc
// has no X any more — the connect step deliberately has no exit, because
// nothing downstream of it works until a model is connected — so `reset()`
// is now reachable only from a completed launch.
//
// Three substitutes were tried and all three were green against a wizard
// with the behaviour deleted, which is worse than no test: routing to "/"
// never withdraws the company; a swap to another company re-points the
// marker by itself, since it stores which company was hydrated rather than
// a bare flag; and either route dance remounts the inner wizard, so the ref
// does not survive to be tested. What the marker guards is still covered
// from the front by "seeds the lead agent's instructions with the mission it
// was never asked for". Restore a real version of this when the arc gains a
// way out.
it("hires under the neutral role, with the name the customer typed", async () => {
// The arc stopped asking for a role, so every onboarding hire is filed
// as `general` — and the hire guard returns *silently* when the role is
+398 -64
View File
@@ -56,6 +56,10 @@ const mockAgentsApi = vi.hoisted(() => ({
}),
),
hire: vi.fn(async () => ({ agent: { id: "agent-1" }, approval: null })),
// The hire step lists the company's agents first and adopts one that already
// carries the typed name on the same source, so a wizard that reopens on the
// agent step cannot hire "Ada 2". Empty by default: the company is new.
list: vi.fn(async () => [] as Array<{ id: string; name: string; adapterType: string }>),
instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })),
saveInstructionsFile: vi.fn(async () => ({})),
// No default implementation: the top-level `beforeEach` sets the "no
@@ -127,6 +131,8 @@ const mockApprovalsApi = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
removeUserSecretDefinition: vi.fn(),
listMyUserSecrets: vi.fn(),
createUserSecretDefinition: vi.fn(),
createMyUserSecret: vi.fn(),
@@ -225,7 +231,6 @@ vi.mock("../adapters/use-adapter-capabilities", () => ({
}));
// Animation / canvas-ish children that add nothing to the logic under test.
vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null }));
vi.mock("./FrontDoor", () => ({ FrontDoor: () => null }));
vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null }));
import { ApiError } from "../api/client";
@@ -311,6 +316,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
session: { id: "session-b", userId: SESSION_USER_ID },
user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null },
});
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockSecretsApi.removeUserSecretDefinition.mockResolvedValue({ ok: true });
window.localStorage.clear();
mockDialog.onboardingOpen = true;
mockDialog.onboardingOptions = {};
@@ -373,16 +381,16 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
vi.clearAllMocks();
});
describe("step 2, which is two screens wearing one number", () => {
// The create path's step 2 was the mission question and is skipped now. The
// grow path's step 2 is "tell us about your team", whose answers seed the
// lead agent — a different screen that happens to share the number, and one
// nothing covered until skipping the first nearly took it along.
describe("step 1 leads straight to the agent — there is no mission step 2", () => {
// One path now: Name your organization → Name your agent → Connect → Get
// started. The Build / Grow front door and both mission screens are gone,
// so "Continue" on step 1 creates the organization and lands on the agent
// step with no mission question in between.
async function openStepOne(path: "create" | "grow") {
async function openStepOne() {
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 1, onboardingPath: path, companyName: "Initech" }),
JSON.stringify({ step: 1, companyName: "Initech" }),
);
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
@@ -416,24 +424,15 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await flushReact();
}
it("keeps the grow path's questionnaire", async () => {
const { root } = await openStepOne("grow");
await clickByText((t) => t.startsWith("Continue"));
expect(document.body.textContent).toContain("Tell us about your team");
expect(mockCompaniesApi.create).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("skips it on the create path, creating the company on the way", async () => {
it("creates the organization on Continue and lands on the agent step, no mission", async () => {
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
const { root } = await openStepOne();
await clickByText((t) => t.startsWith("Continue"));
expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Initech" });
expect(document.body.textContent).toContain("Create your first agent");
expect(document.body.textContent).not.toContain("Define your mission");
expect(document.body.textContent).not.toContain("Tell us about your team");
await act(async () => root.unmount());
});
@@ -446,7 +445,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// render unchecked. Both asserted against positive anchors so an
// unrendered step cannot pass as an absence.
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
const { root } = await openStepOne();
await clickByText((t) => t.startsWith("Continue"));
expect(document.body.textContent).toContain("Create your first agent");
@@ -481,6 +480,77 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
it("adopts an agent the company already has under that name instead of hiring it twice", async () => {
// The wizard can reopen on the agent step for a company that just got
// its first agent — the dashboard's agentless offer on a stale list is
// one way — with nothing in its state to say the hire happened. The
// server numbers a repeat name, so without this the walk produced
// "Ada" and "Ada 2". Same name on the same source is the same agent.
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue([]);
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
mockAgentsApi.list.mockResolvedValueOnce([
{ id: "agent-existing", name: "Ada", adapterType: "claude_local" },
]);
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
const clickText = async (match: (t: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
};
const nameField = document.body.querySelector(
"#onboarding-company-name",
) as HTMLInputElement | null;
if (nameField) {
await act(async () => {
setControlledValue(nameField, "Initech");
});
await flushReact();
} else {
const anyName = document.body.querySelector(
'input[placeholder="e.g. Northwind Labs"]',
) as HTMLInputElement;
await act(async () => {
setControlledValue(anyName, "Initech");
});
await flushReact();
}
await clickText((t) => t.startsWith("Continue"));
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
setControlledValue(agentField, "ada ");
});
await flushReact();
await clickText((t) => isArcPrimary(t));
await pickFirstSource(clickText);
await clickText((t) => isArcPrimary(t));
expect(mockAgentsApi.list).toHaveBeenCalledWith("company-new");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("ada is ready to work!");
await act(async () => root.unmount());
});
it("hires from a legacy draft that saved an empty role", async () => {
// `agentRole: ""` was this field's default before the arc stopped asking
// for a role, so every draft saved by an earlier build carries it. `??`
@@ -489,7 +559,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// through a restored draft instead of a fresh one.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 1, onboardingPath: "create", companyName: "Initech", agentRole: "" }),
JSON.stringify({ step: 1, companyName: "Initech", agentRole: "" }),
);
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
@@ -556,7 +626,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
}),
);
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
const { root } = await openStepOne();
await clickByText((t) => t.startsWith("Continue"));
const agentField = document.body.querySelector(
"#onboarding-agent-name",
@@ -593,7 +663,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// while the same event is still bubbling — so the second caller reads a
// value the first has not written. Two companies, one keystroke.
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
const { root } = await openStepOne();
const nameInput = document.body.querySelector(
'input[placeholder="e.g. Northwind Labs"]',
@@ -624,7 +694,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
resolveCreate = resolve;
}),
);
const { root } = await openStepOne("create");
const { root } = await openStepOne();
const nameInput = document.body.querySelector(
'input[placeholder="e.g. Northwind Labs"]',
@@ -650,7 +720,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// A create run reached the agent step from step 1, so Back owes it step 1 —
// not the mission screen it never saw.
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
const { root } = await openStepOne();
await clickByText((t) => t.startsWith("Continue"));
expect(document.body.textContent).toContain("Create your first agent");
@@ -674,7 +744,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 1, onboardingPath: "create", companyName: "Initech" }),
JSON.stringify({ step: 1, companyName: "Initech" }),
);
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
@@ -790,6 +860,32 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
* what this step did before, and what the Claude token path has always
* avoided by holding a `user_secret_ref` instead.
*/
it.each(["personal", "organization"])("defaults to a saved %s API key and uses the same reference for probe and hire", async (scope) => {
const key = "ANTHROPIC_API_KEY";
const binding = scope === "personal"
? { type: "user_secret_ref", key, version: "latest" }
: { type: "secret_ref", secretId: "saved-org-key", version: "latest" };
if (scope === "personal") {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([{
definition: { id: "saved-key", companyId: "company-new", key, name: "Saved key", status: "active" },
secret: { companyId: "company-new", status: "active" },
}]);
} else {
mockSecretsApi.list.mockResolvedValue([{
id: "saved-org-key", companyId: "company-new", key, name: "Saved key", scope: "company", status: "active",
}]);
}
const { root, clickByText } = await openConnectStep();
const picker = document.body.querySelector('select[aria-label="Saved API key"]') as HTMLSelectElement;
expect(picker.value).toBe(scope === "personal" ? "user:saved-key" : "company:saved-org-key");
await clickByText((t) => isArcPrimary(t));
expect((mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[])[2]).toMatchObject({ adapterConfig: { env: { [key]: binding } } });
expect((mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1]).toMatchObject({ adapterConfig: { env: { [key]: binding } } });
expect(mockSecretsApi.createMyUserSecret).not.toHaveBeenCalled();
expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
describe("an API key typed on the step", () => {
const KEY = "sk-ant-typed-by-the-customer";
@@ -827,7 +923,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
string,
{ definitionKey: string; value: string },
];
expect(createBody.definitionKey).toBe("ANTHROPIC_API_KEY");
expect(createBody.definitionKey).toMatch(/^ANTHROPIC_API_KEY\.setup\./);
expect(createBody.value).toBe(KEY);
const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as {
@@ -836,7 +932,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// The same binding kind the subscription half of this step produces.
expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toEqual({
type: "user_secret_ref",
key: "ANTHROPIC_API_KEY",
key: createBody.definitionKey,
version: "latest",
});
// The whole payload, not just that one field: the point is that the key
@@ -846,39 +942,16 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
// Onboarding is the first thing to need this definition, so it creates it.
it("creates the definition once, then reuses it", async () => {
await connectWithApiKey();
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledTimes(1);
it("creates a distinct definition instead of rotating an existing key", async () => {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
{ definition: { id: "def-1", key: "ANTHROPIC_API_KEY" }, secret: null },
{ definition: { id: "old-def", key: "ANTHROPIC_API_KEY" }, secret: { id: "secret-existing" } },
]);
const { root } = await connectWithApiKey();
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
// A second value against one definition is what the server refuses, so a
// customer who already has a key stored must rotate rather than add.
it("rotates an existing value instead of storing a second one", async () => {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
{
definition: { id: "def-1", key: "ANTHROPIC_API_KEY" },
secret: { id: "secret-existing" },
},
]);
const { root } = await connectWithApiKey();
expect(mockSecretsApi.rotateMyUserSecret).toHaveBeenCalledWith(
expect.any(String),
"secret-existing",
{ value: KEY },
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledWith(
expect.any(String), expect.objectContaining({ key: expect.stringMatching(/^ANTHROPIC_API_KEY\.setup\./) }),
);
expect(mockSecretsApi.createMyUserSecret).not.toHaveBeenCalled();
expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled();
expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
@@ -1034,9 +1107,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
// The status route must not even be asked — the conflict is decided
// from the adapter configuration alone, before any network round trip.
expect(mockAgentsApi.getClaudeOAuthTokenStatus).not.toHaveBeenCalled();
// Discovery reads saved-login metadata once; the hire does not re-read
// or apply it when the configuration already has an API key.
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(1);
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
const hireBody = hireArgs[1] as {
adapterConfig: { env?: Record<string, unknown> };
@@ -1198,11 +1271,12 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockAgentsApi.hire.mockRejectedValue(new Error("hire failed"));
const { root, clickByText } = await openConnectStep();
const discoveryReads = mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length;
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(discoveryReads + 1);
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(2);
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(discoveryReads + 2);
await act(async () => root.unmount());
});
@@ -1912,7 +1986,6 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 4,
onboardingPath: "create",
companyName: "Initech",
agentName: "Ada",
createdCompanyId: "company-new",
@@ -2233,10 +2306,271 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// followed was an input that had just gone blank — reported from staging
// as the paste looking dropped, or the step looking stuck.
expect(field!.value).toBe("Q2RJ-E1YIF-authorization-code");
// As dots. The code is kept so the customer can see the paste landed,
// and that is all the field needs to show of it.
expect(field!.type).toBe("password");
// And the button answers the paste itself. The status here never reaches
// authenticated, so this is "Connecting" before any server confirmation —
// waiting for that left about a second of a button still reading
// "Waiting for code" after the code had gone in.
expect(
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim(),
).toBe("Connecting");
await act(async () => root.unmount());
});
it("does not hire on the paste alone, before the login is stored", async () => {
// "Connecting" appears at the paste now, ahead of the server confirming
// anything. The two-second hold used to start at that same moment, so
// moving one without the other would hire at the paste plus two seconds
// whether or not a credential existed. The status here stays pending, so
// the login is never stored.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 4; i++) await flushReact();
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
// The paste really did start Connecting; without this the assertion
// below would hold for a flow that never got that far.
expect(cta(), "the paste should have started Connecting").toBe("Connecting");
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400));
});
for (let i = 0; i < 4; i++) await flushReact();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(cta()).toBe("Connecting");
await act(async () => root.unmount());
});
it("gives the button back when the pasted code is refused", async () => {
// The other half of answering the paste early: a button that says
// "Connecting" before the server answers has to stop saying it when the
// answer is no, or it spins on a login that is not coming.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockRejectedValueOnce(
new Error("That authorization code was not accepted."),
);
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(cta()).toBe("Sign in to Claude");
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 8; i++) await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("That authorization code was not accepted.");
expect(cta()).toBe("Sign in to Claude");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("does not reopen the card when a pasted code fails after Back", async () => {
// The panel stays mounted through Back's exit, so its report of a failed
// submit can land mid-exit. Restoring the button there reopened the card
// the customer was leaving, without the address Back had cleared.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
let refuse: (error: Error) => void = () => {};
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
refuse = reject;
}),
);
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 4; i++) await flushReact();
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1);
expect(cta(), "the paste should have started Connecting").toBe("Connecting");
// Hold the exit open so the refusal lands inside it. Without a
// `matchMedia` to ask, every beat collapses to zero and the exit would be
// over before the refusal arrived — which would pass for the wrong reason.
const realMatchMedia = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
try {
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
refuse(new Error("That authorization code was not accepted."));
});
for (let i = 0; i < 4; i++) await flushReact();
// Still leaving: the button shows the step's resting face, not the
// sign-in it would have reopened.
expect(cta()).toBe("Next");
// And the exit finishes — the row is a question again. Waited in short
// slices, each its own `act`. One long `act` defers React's commits to
// its end, so a beat's timer fires on time but its phase only commits
// when the wait is over — and the next beat is scheduled only then. The
// exit crawls one step per wait and never gets back to the question.
for (let i = 0; i < 30; i++) {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 50));
});
}
expect(
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center"),
).toBe(false);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: realMatchMedia,
});
}
await act(async () => root.unmount());
});
it("does not hire when the login finishes after Back", async () => {
// The same window from the other side. A login can complete while Back's
// exit is still running, and reporting that success pulled the step back
// into "Connecting" and on into a hire the customer had backed away from.
// No paste needed: here the server has already authenticated, and the
// completion read is simply slow.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "authenticated",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
let finishCompletion: (value: { storedSessionId: string }) => void = () => {};
mockAgentsApi.completeClaudeSetupTokenLogin.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishCompletion = resolve;
}),
);
const realMatchMedia = window.matchMedia;
try {
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
for (let i = 0; i < 6; i++) await flushReact();
// The completion read is out and has not answered, and the card is up.
expect(mockAgentsApi.completeClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(cta()).toBe("Sign in to Claude");
// Hold the exit open, as above, so the success lands inside it.
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
finishCompletion({ storedSessionId: "stored-1" });
});
for (let i = 0; i < 4; i++) await flushReact();
expect(cta()).toBe("Next");
// Past the exit, and past the full hold a late success would have
// started. In short slices, each its own `act`, for the reason given in
// the test above — and here it matters twice: a hire scheduled by a late
// "Connecting" is only scheduled once that phase commits, so one long
// `act` would hide the very hire this is looking for.
const slices = Math.ceil((CONNECTED_HOLD_MS + 1200) / 50);
for (let i = 0; i < slices; i++) {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 50));
});
}
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center"),
).toBe(false);
await act(async () => root.unmount());
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: realMatchMedia,
});
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
}
}, 15_000);
it("starts the sign-in on the first press, even when it changes the adapter", async () => {
// The regression this is here for. Picking a source sets the phase *and*
// the adapter, and a reset keyed on the adapter then put the phase
File diff suppressed because it is too large. Load diff
@@ -1,69 +0,0 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// The onboarding wizard's decorative right-hand panel (which renders the
// ASCII paperclip illustration) must follow the active shadcn theme instead of
// hardcoding a dark surface. Otherwise a light/cream deployer theme (set via
// the PAPERCLIP_DEFAULT_THEME bootstrap) renders a jarring cream form next to a
// solid dark panel. The illustration glyphs already use `text-muted-foreground`,
// so the panel must sit on the paired `bg-muted` surface to read as an
// intentional ink-on-surface texture in every theme.
//
// Asserting against the source keeps this guard cheap: the panel className is a
// static string literal, and the full wizard dialog is too heavy to mount here.
const here = path.dirname(fileURLToPath(import.meta.url));
describe("OnboardingWizard decorative panel theming", () => {
const source = readFileSync(
path.join(here, "OnboardingWizard.tsx"),
"utf8"
);
/**
* The className expression on the wrapper around <AsciiArtAnimation />.
* Anchoring here keeps both guards on the decorative panel itself - the same
* tokens appear elsewhere in the wizard. `[^<>]` stops the match spanning
* into other JSX elements.
*/
function panelClassNames(): string | null {
return (
source.match(/className=\{cn\(([^<>]*)\)\}\s*>\s*<AsciiArtAnimation\s*\/>/)?.[1] ??
null
);
}
it("gives the decorative panel no background but the muted token", () => {
// Scoped to the panel, not the file. A file-wide scan would fail this
// case for a hardcoded colour anywhere else in the wizard, reporting a
// panel regression that had not happened.
//
// Asserted as the complete set of `bg-` classes rather than a list of
// spellings to forbid. The previous version scanned for `bg-[#rrggbb]`
// alone, and passed unchanged once master migrated this class to
// `bg-(--hex-1d1d1d)` - so it guarded nothing at all for a while. A named
// colour like `bg-black` or `bg-zinc-900` would have slipped through the
// same way. Naming what is allowed cannot rot like that.
const panel = panelClassNames();
expect(panel).not.toBeNull();
const backgrounds = panel!.match(/\bbg-[^\s"'`,]+/g) ?? [];
expect(backgrounds).toEqual(["bg-muted"]);
});
it("themes the decorative panel with shadcn surface tokens", () => {
// Anchor to the wrapper around <AsciiArtAnimation /> so the guard checks
// the decorative panel itself (the same tokens appear elsewhere in the
// wizard), then assert each token independently so a class reorder or a
// utility inserted between them cannot false-fail the test. `[^<>]`
// keeps the match from spanning across other JSX elements.
const panel = source.match(
/className=\{cn\(([^<>]*)\)\}\s*>\s*<AsciiArtAnimation\s*\/>/
);
expect(panel).not.toBeNull();
expect(panel?.[1]).toMatch(/\bbg-muted\b(?!-)/);
expect(panel?.[1]).toMatch(/\btext-muted-foreground\b/);
});
});
+16 -12
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSidebar } from "../context/SidebarContext";
@@ -19,18 +20,21 @@ export function PageTabBar({ items, value, onValueChange, align = "center" }: Pa
if (isMobile && value !== undefined && onValueChange) {
return (
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-9 rounded-md border border-border bg-background px-2 py-1 text-base focus:outline-none focus:ring-1 focus:ring-ring"
aria-label="Page section"
>
{items.map((item) => (
<option key={item.value} value={item.value}>
{typeof item.label === "string" ? item.label : item.value}
</option>
))}
</select>
<div className="relative inline-flex">
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-9 appearance-none rounded-md border border-border bg-background pl-3 pr-9 py-1 text-base focus:outline-none focus:ring-1 focus:ring-ring"
aria-label="Page section"
>
{items.map((item) => (
<option key={item.value} value={item.value}>
{typeof item.label === "string" ? item.label : item.value}
</option>
))}
</select>
<ChevronDown aria-hidden="true" className="pointer-events-none absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
</div>
);
}
@@ -125,10 +125,10 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli
expect(buttonLabels()).not.toContain("Change local folder");
expect(container.querySelector('button[aria-label="Clear local folder"]')).toBeNull();
// The repo row is unrelated to the host filesystem, so it stays.
expect(container.textContent).toContain("Repo");
expect(container.textContent).toContain("Source repos");
});
it("keeps only the managed-folder label for a managed checkout when the policy is on", () => {
it("omits the host codebase section for a managed checkout when the policy is on", () => {
render(
makeProject(makeCodebase({
localFolder: null,
@@ -138,7 +138,7 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli
{ enableIsolatedWorkspaces: true, enableManagedSandboxOnly: true },
);
expect(container.textContent).toContain("Paperclip-managed folder.");
expect(container.textContent).not.toContain("Codebase");
expect(container.textContent).not.toContain(MANAGED_FOLDER);
expect(container.querySelector(".font-mono")?.textContent).not.toBe(MANAGED_FOLDER);
expect(buttonLabels()).not.toContain("Set local folder");
@@ -151,7 +151,7 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli
expect(container.textContent).not.toContain("Local folder");
expect(container.textContent).not.toContain(LOCAL_FOLDER);
expect(container.textContent).toContain("Repo");
expect(container.textContent).toContain("Source repos");
});
it("never opens the absolute-path edit panel when the policy is on", () => {
+15 -328
View File
@@ -1,24 +1,20 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment";
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared";
import { StatusBadge } from "./StatusBadge";
import { ProjectRepositories } from "./ProjectRepositories";
import { cn, formatDate } from "../lib/utils";
import { environmentsApi } from "../api/environments";
import { goalsApi } from "../api/goals";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { secretsApi } from "../api/secrets";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Plus, Trash2, X } from "lucide-react";
import { GithubIcon } from "@/components/icons/github-icon";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Trash2 } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives";
@@ -26,16 +22,9 @@ import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { Badge } from "@/components/ui/badge";
const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" },
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
];
interface ProjectPropertiesProps {
project: Project;
repositories?: ReactNode;
onUpdate?: (data: Record<string, unknown>) => void;
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
@@ -147,42 +136,6 @@ function PropertyRow({
);
}
function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (status: string) => void }) {
const [open, setOpen] = useState(false);
const colorClass = statusBadge[status] ?? statusBadgeDefault;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap shrink-0 cursor-pointer hover:opacity-80 transition-opacity",
colorClass,
)}
>
{status.replace("_", " ")}
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{PROJECT_STATUSES.map((s) => (
<Button
key={s.value}
variant="ghost"
size="sm"
className={cn("w-full justify-start gap-2 text-xs", s.value === status && "bg-accent")}
onClick={() => {
onChange(s.value);
setOpen(false);
}}
>
{s.label}
</Button>
))}
</PopoverContent>
</Popover>
);
}
function ArchiveDangerZone({
project,
onArchive,
@@ -248,14 +201,12 @@ function ArchiveDangerZone({
);
}
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const [goalOpen, setGoalOpen] = useState(false);
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null);
const [workspaceCwd, setWorkspaceCwd] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const commitField = (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
@@ -267,11 +218,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
};
const fieldState = (field: ProjectConfigFieldKey): ProjectFieldSaveState => getFieldSaveState?.(field) ?? "idle";
const { data: allGoals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
@@ -307,24 +253,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
enabled: !!selectedCompanyId && environmentsEnabled,
});
const linkedGoalIds = project.goalIds.length > 0
? project.goalIds
: project.goalId
? [project.goalId]
: [];
const linkedGoals = project.goals.length > 0
? project.goals
: linkedGoalIds.map((id) => ({
id,
title: allGoals?.find((g) => g.id === id)?.title ?? id.slice(0, 8),
}));
const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id));
const workspaces = project.workspaces ?? [];
const codebase = project.codebase;
const primaryCodebaseWorkspace = project.primaryWorkspace ?? null;
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id);
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId);
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
@@ -373,7 +305,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
mutationFn: (data: Record<string, unknown>) => projectsApi.createWorkspace(project.id, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
@@ -384,7 +315,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
@@ -395,24 +325,12 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
projectsApi.updateWorkspace(project.id, workspaceId, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const removeGoal = (goalId: string) => {
if (!onUpdate && !onFieldUpdate) return;
commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) });
};
const addGoal = (goalId: string) => {
if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return;
commitField("goals", { goalIds: [...linkedGoalIds, goalId] });
setGoalOpen(false);
};
const updateExecutionWorkspacePolicy = (patch: Record<string, unknown>) => {
if (!onUpdate && !onFieldUpdate) return;
return {
@@ -428,17 +346,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const looksLikeRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
if (parsed.protocol !== "https:") return false;
const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2;
} catch {
return false;
}
};
const isSafeExternalUrl = (value: string | null | undefined) => {
if (!value) return false;
try {
@@ -449,20 +356,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
}
};
const formatRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length < 2) return parsed.host;
const owner = segments[0];
const repo = segments[1]?.replace(/\.git$/i, "");
if (!owner || !repo) return parsed.host;
return `${parsed.host}/${owner}/${repo}`;
} catch {
return value;
}
};
const deriveSourceType = (cwd: string | null, repoUrl: string | null) => {
if (repoUrl) return "git_repo";
if (cwd) return "local_path";
@@ -509,21 +402,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
persistCodebase({ cwd });
};
const submitRepoWorkspace = () => {
const repoUrl = workspaceRepoUrl.trim();
if (!repoUrl) {
setWorkspaceError(null);
persistCodebase({ repoUrl: null });
return;
}
if (!looksLikeRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL.");
return;
}
setWorkspaceError(null);
persistCodebase({ repoUrl });
};
const clearLocalWorkspace = () => {
const confirmed = window.confirm(
codebase.repoUrl
@@ -534,24 +412,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
persistCodebase({ cwd: null });
};
const clearRepoWorkspace = () => {
const hasLocalFolder = Boolean(codebase.localFolder);
const confirmed = window.confirm(
hasLocalFolder
? "Clear repo from this workspace?"
: "Delete this workspace repo?",
);
if (!confirmed) return;
if (primaryCodebaseWorkspace && hasLocalFolder) {
updateWorkspace.mutate({
workspaceId: primaryCodebaseWorkspace.id,
data: { repoUrl: null, repoRef: null, defaultRef: null, sourceType: deriveSourceType(codebase.localFolder, null) },
});
return;
}
persistCodebase({ repoUrl: null });
};
return (
<div>
<div className="space-y-1 pb-4">
@@ -589,83 +449,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</p>
)}
</PropertyRow>
<PropertyRow label={<FieldLabel label="Status" state={fieldState("status")} />}>
{onUpdate || onFieldUpdate ? (
<ProjectStatusPicker
status={project.status}
onChange={(status) => commitField("status", { status })}
/>
) : (
<StatusBadge status={project.status} />
)}
</PropertyRow>
{project.leadAgentId && (
<PropertyRow label="Lead">
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
</PropertyRow>
)}
<PropertyRow
label={<FieldLabel label="Goals" state={fieldState("goals")} />}
alignStart
valueClassName="space-y-2"
>
{linkedGoals.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{linkedGoals.map((goal) => (
<span
key={goal.id}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
>
<Link to={`/goals/${goal.id}`} className="hover:underline break-words min-w-0">
{goal.title}
</Link>
{(onUpdate || onFieldUpdate) && (
<button
className="text-muted-foreground hover:text-foreground"
type="button"
onClick={() => removeGoal(goal.id)}
aria-label={`Remove goal ${goal.title}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
)}
{(onUpdate || onFieldUpdate) && (
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="xs"
className={cn("h-6 w-fit px-2", linkedGoals.length > 0 && "ml-1")}
disabled={availableGoals.length === 0}
>
<Plus className="h-3 w-3 mr-1" />
Goal
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="start">
{availableGoals.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
All goals linked.
</div>
) : (
availableGoals.map((goal) => (
<button
key={goal.id}
className="flex items-center w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => addGoal(goal.id)}
>
{goal.title}
</button>
))
)}
</PopoverContent>
</Popover>
)}
</PropertyRow>
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
<PropertyRow
label={<FieldLabel label="Env" state={fieldState("env")} />}
alignStart
@@ -673,6 +457,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
>
<div className="space-y-2">
<EnvironmentVariablesEditor
footerHint={null}
value={project.env ?? {}}
secrets={availableSecrets}
userSecretDefinitions={userSecretDefinitions}
@@ -682,14 +467,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
}}
onChange={(env) => commitField("env", { env: env ?? null })}
/>
<p className="text-(length:--text-micro) text-muted-foreground">
Applied to all runs for tasks in this project. Project values override agent env on key conflicts.
</p>
</div>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Updated" state="idle" />}>
<span className="text-sm">{formatDate(project.updatedAt)}</span>
</PropertyRow>
@@ -703,7 +483,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<Separator className="my-4" />
<div className="space-y-1 py-4">
<div className="space-y-2">
{(!hideHostPaths || (primaryCodebaseWorkspace?.runtimeServices?.length ?? 0) > 0) && <div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Codebase</span>
<Tooltip>
@@ -724,69 +504,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</Tooltip>
</div>
<div className="space-y-2 rounded-md border border-border/70 p-3">
<div className="space-y-1">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Repo</div>
{codebase.repoUrl ? (
<div className="flex items-center justify-between gap-2">
{isSafeExternalUrl(codebase.repoUrl) ? (
<a
href={codebase.repoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground hover:underline"
>
<GithubIcon className="h-3 w-3 shrink-0" />
<span className="break-all min-w-0">{formatRepoUrl(codebase.repoUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
) : (
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<GithubIcon className="h-3 w-3 shrink-0" />
<span className="break-all min-w-0">{codebase.repoUrl}</span>
</div>
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode("repo");
setWorkspaceRepoUrl(codebase.repoUrl ?? "");
setWorkspaceError(null);
}}
>
Change repo
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={clearRepoWorkspace}
aria-label="Clear repo"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">Not set.</div>
<Button
variant="outline"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode("repo");
setWorkspaceRepoUrl(codebase.repoUrl ?? "");
setWorkspaceError(null);
}}
>
Set repo
</Button>
</div>
)}
</div>
{/*
The local folder is an absolute path on the execution host. Under
the managed-sandbox-only policy every agent runs in the
@@ -939,39 +656,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</div>
</div>
)}
{workspaceMode === "repo" && (
<div className="space-y-1.5 rounded-md border border-border p-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
placeholder="https://github.com/org/repo"
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
disabled={(!workspaceRepoUrl.trim() && !primaryCodebaseWorkspace) || createWorkspace.isPending || updateWorkspace.isPending}
onClick={submitRepoWorkspace}
>
Save
</Button>
<Button
variant="ghost"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode(null);
setWorkspaceRepoUrl("");
setWorkspaceError(null);
}}
>
Cancel
</Button>
</div>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
@@ -984,7 +668,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
{updateWorkspace.isError && (
<p className="text-xs text-destructive">Failed to update workspace.</p>
)}
</div>
</div>}
{isolatedWorkspacesEnabled ? (
<>
@@ -1324,6 +1008,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</div>
</>
)}
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { LegacyProjectRepository } from "./LegacyProjectRepository";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Project, ProjectRepository } from "@paperclipai/shared";
import { projectsApi } from "@/api/projects";
import { queryKeys } from "@/lib/queryKeys";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import { Button } from "./ui/button";
export function ProjectRepositories({ project }: { project: Project }) {
const client = useQueryClient();
const saved: ProjectRepository[] = project.workspaces.flatMap((workspace) => {
const id = workspace.metadata?.githubRepositoryId;
return typeof id === "string" && workspace.repoUrl ? [{ id, fullName: workspace.name, url: workspace.repoUrl, connections: [] }] : [];
});
const [draft, setDraft] = useState<ProjectRepository[] | null>(null);
const [connecting, setConnecting] = useState(false);
const save = useMutation({
mutationFn: () => projectsApi.setRepositories(project.id, (draft ?? saved).map((repo) => repo.id)),
onSuccess: (updated) => {
for (const ref of new Set([project.id, project.urlKey])) {
client.setQueriesData({ queryKey: queryKeys.projects.detail(ref) }, updated);
void client.invalidateQueries({ queryKey: queryKeys.projects.detail(ref) });
}
void client.invalidateQueries({ queryKey: queryKeys.projects.all(project.companyId) });
void client.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
setDraft(null);
},
});
return <section aria-label="Repositories" className="flex min-w-0 flex-col gap-4 py-4">
<ProjectRepositoryInput companyId={project.companyId} selected={draft ?? saved} onChange={(repos) => { setDraft(repos); save.reset(); }} onConnect={() => setConnecting(true)} disabled={save.isPending} />
{project.workspaces.filter((workspace) => workspace.repoUrl && !workspace.metadata?.githubRepositoryId).map((workspace) => <LegacyProjectRepository key={workspace.id} workspace={workspace} projectRef={project.urlKey} />)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
{save.isError && <p role="alert" className="mr-auto text-sm text-destructive">{save.error.message}</p>}
{save.isSuccess && <span role="status" className="mr-auto text-sm text-muted-foreground">Changes saved</span>}
{draft && <Button type="button" variant="ghost" disabled={save.isPending} onClick={() => { setDraft(null); save.reset(); }}>Discard changes</Button>}
<Button type="button" disabled={!draft || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Saving…" : "Save changes"}</Button>
</div>
<Dialog open={connecting} onOpenChange={setConnecting}><DialogContent showCloseButton={false} aria-describedby={undefined} className="max-h-(--sz-calc-18) overflow-y-auto sm:max-w-2xl">
<DialogTitle className="sr-only">Connect GitHub</DialogTitle>
<ConnectionSetupFlow host="dialog" serviceSlug="github" forceNewConnection onCancel={() => setConnecting(false)} onComplete={() => {
void client.invalidateQueries({ queryKey: repositoryOptionsKey(project.companyId) });
setConnecting(false);
}} />
</DialogContent></Dialog>
</section>;
}
@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import type { ProjectRepository } from "@paperclipai/shared";
import { projectsApi } from "@/api/projects";
import { RepositoryEditor } from "./RepositoryEditor";
import { Button } from "./ui/button";
export const repositoryOptionsKey = (companyId: string) => ["project-repositories", companyId] as const;
export function ProjectRepositoryInput({ companyId, selected, onChange, onConnect, disabled }: {
companyId: string;
selected: ProjectRepository[];
onChange: (repos: ProjectRepository[]) => void;
onConnect: () => void;
disabled?: boolean;
}) {
const query = useQuery({ queryKey: repositoryOptionsKey(companyId), queryFn: () => projectsApi.repositoryOptions(companyId), staleTime: 30_000 });
const state = query.isPending ? "loading" : query.isError ? "error"
: !query.data.connectionCount ? "disconnected"
: query.data.failedConnectionCount && !query.data.repositories.length ? "error"
: !query.data.repositories.length ? "empty" : "ready";
return <div className="flex min-w-0 flex-col gap-3">
<RepositoryEditor selected={selected.map((repo) => query.data?.repositories.find((available) => available.id === repo.id) ?? repo)} onChange={onChange} available={query.data?.repositories} state={state}
onRetry={() => void query.refetch()} onConnect={onConnect} disabled={disabled} />
{!!query.data?.failedConnectionCount && query.data.repositories.length > 0 && <div role="alert" className="flex flex-wrap items-center gap-2 text-xs text-destructive">
Some GitHub connections could not load. Reconnect them in Apps or try again.
<Button type="button" variant="ghost" size="sm" disabled={query.isFetching} onClick={() => void query.refetch()}>Try again</Button>
</div>}
</div>;
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from "react";
import type { ProjectRepository } from "@paperclipai/shared";
import { GitBranch, LockKeyhole, Plus, X } from "lucide-react";
import { GithubIcon } from "./icons/github-icon";
import { SearchableSelect } from "./SearchableSelect";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
function RepoRow({ repo, onRemove }: { repo: ProjectRepository; onRemove: () => void }) {
return (
<div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2">
<GithubIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-sm font-medium" title={repo.fullName}>{repo.fullName}</span>
{repo.connections.length > 0 && <span className="truncate text-xs text-muted-foreground" title={repo.connections.join(" · ")}>
{repo.connections.join(" · ")}
</span>}
</div>
{repo.private && <LockKeyhole className="size-3 shrink-0 text-muted-foreground" aria-label="Private repository" />}
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove ${repo.fullName}`} onClick={onRemove}><X className="size-4" /></Button>
</div>
);
}
/** Shared by both review surfaces, ready to extract after design approval. */
export function RepositoryEditor({ selected, onChange, state = "ready", available = [], onRetry, onConnect, disabled = false }: {
selected: ProjectRepository[];
onChange: (repos: ProjectRepository[]) => void;
state?: "ready" | "loading" | "disconnected" | "empty" | "error";
available?: ProjectRepository[];
onRetry: () => void;
disabled?: boolean;
onConnect: () => void;
}) {
const [showPicker, setShowPicker] = useState(false);
const picker = useRef<HTMLDivElement>(null);
useEffect(() => {
if (showPicker) picker.current?.querySelector<HTMLButtonElement>('[role="combobox"]')?.focus();
}, [showPicker]);
const options = available.filter((repo) => !selected.some((item) => item.id === repo.id)).map((repo) => ({
key: repo.id, value: repo.id, label: repo.fullName,
searchText: repo.connections.join(" "), repo,
}));
const addClassName = selected.length ? "self-start" : "h-24 w-full flex-col gap-2";
const addLabel = selected.length ? "Add another repo" : "Add GitHub repo";
return (
<fieldset disabled={disabled} className="flex min-w-0 flex-col gap-3">
<div className="flex items-baseline gap-2"><span className="text-sm font-medium">Source repos</span><span className="text-xs text-muted-foreground">optional</span></div>
{selected.map((repo) => <RepoRow key={repo.id} repo={repo} onRemove={() => onChange(selected.filter((item) => item.id !== repo.id))} />)}
{state === "disconnected" ? (
<Popover>
<PopoverTrigger asChild><Button type="button" variant="outline" className={addClassName}><GithubIcon className="size-4" />{addLabel}</Button></PopoverTrigger>
<PopoverContent align="start" className="w-72">
<div className="flex flex-col gap-3">
<GithubIcon className="size-5" />
<div className="flex flex-col gap-1"><p className="text-sm font-medium">Connect GitHub to pick a repo</p><p className="text-xs text-muted-foreground">Choose from repos you can access through your GitHub connections.</p></div>
<Button type="button" onClick={onConnect}><GithubIcon className="size-4" />Connect GitHub</Button>
</div>
</PopoverContent>
</Popover>
) : showPicker ? (
<div ref={picker} className="flex flex-col gap-2">
<SearchableSelect<string, (typeof options)[number]>
value="" groups={[{ id: "available", label: "Available GitHub repos", options }]}
placeholder={addLabel} searchPlaceholder="Search GitHub repos…"
contentClassName="max-h-(--radix-popover-content-available-height) overflow-hidden [&_[data-slot=command]]:max-h-(--radix-popover-content-available-height) [&_[data-slot=command-list]]:min-h-0 [&_[data-slot=command-list]]:flex-1 [&_[data-slot=command-input-wrapper]]:shrink-0"
loading={state === "loading"} loadingMessage="Loading GitHub repos…"
emptyMessage={state === "error" ? "Couldn’t load GitHub repos. Try again." : state === "empty" ? "No repos available. Connect an account with repo access." : "No matching repos. Try another search or connection."}
renderValue={() => <span className="flex items-center gap-2 text-foreground"><GithubIcon className="size-4" />{addLabel}</span>}
renderOption={({ repo }) => <><GitBranch className="size-4 shrink-0 text-muted-foreground" /><span className="flex min-w-0 flex-1 flex-col gap-1"><span className="truncate">{repo.fullName}</span><span className="truncate text-xs text-muted-foreground">{repo.connections.join(" · ")}</span></span>{repo.private && <LockKeyhole className="size-3 shrink-0 text-muted-foreground" />}</>}
onValueChange={(_, option) => { onChange([...selected, option.repo]); setShowPicker(false); }}
createItem={state === "error"
? { render: () => <>Try again</>, onSelect: () => { onRetry(); } }
: { render: () => <><Plus className="size-4" />Connect another GitHub account</>, onSelect: onConnect }}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{"All GitHub connections you can use."}</p>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowPicker(false)}>Cancel</Button>
</div>
</div>
) : <Button type="button" variant="outline" className={addClassName} onClick={() => setShowPicker(true)}><GithubIcon className="size-4" />{addLabel}</Button>}
</fieldset>
);
}
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -28,18 +29,11 @@ function Harness({ onRoute }: { onRoute: boolean }) {
);
}
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
flushSync(() => {});
}
async function render(onRoute: boolean): Promise<{ root: Root; host: HTMLDivElement }> {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
flushSync(() => root.render(<Harness onRoute={onRoute} />));
await flushReact();
await act(async () => root.render(<Harness onRoute={onRoute} />));
return { root, host };
}
@@ -71,9 +65,9 @@ describe("RequestCollapsedSidebar", () => {
});
});
afterEach(() => {
afterEach(async () => {
if (active) {
flushSync(() => active!.root.unmount());
await act(async () => active!.root.unmount());
active.host.remove();
active = null;
}
@@ -100,8 +94,7 @@ describe("RequestCollapsedSidebar", () => {
expect(capturedValue?.collapsed).toBe(false);
// Navigate away: the route (and its <RequestCollapsedSidebar/>) unmounts.
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
await act(async () => active!.root.render(<Harness onRoute={false} />));
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(false);
});
@@ -111,8 +104,7 @@ describe("RequestCollapsedSidebar", () => {
flushSync(() => capturedValue?.setCollapsed(true));
expect(localStorage.getItem("paperclip.sidebar.collapsed")).toBeNull();
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
await act(async () => active!.root.render(<Harness onRoute={false} />));
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(false);
});
@@ -154,6 +154,14 @@ describe("RunnerInspector", () => {
expect(container.textContent).toContain(
"Raw provider capture was off for this run.",
);
flushSync(() =>
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Overview")
?.click(),
);
await flush();
expect(container.textContent).toContain("Raw capture off");
expect(container.textContent).not.toContain("Expired");
const rerunButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Re-run with provider trace"),
);
+1 -1
View File
@@ -1090,7 +1090,7 @@ export function RunnerInspector({
<StatCard label="Operations" value={operations.length} detail="correlated groups" />
<StatCard label="PRP events" value={events.length} detail={`${visibleEventCount} visible`} />
<StatCard label="Mappings" value={interpretations.length} detail={`${ignoredCount} ignored`} />
<StatCard label="Run status" value={run?.status ?? "unknown"} detail={traceBadgeLabel(inspection?.trace?.status ?? "expired", inspection?.trace?.expiresAt ?? new Date(0))} />
<StatCard label="Run status" value={run?.status ?? "unknown"} detail={inspection?.trace ? traceBadgeLabel(inspection.trace.status, inspection.trace.expiresAt) : "Raw capture off"} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<section className="rounded-lg border border-border bg-card p-4">
+157
View File
@@ -0,0 +1,157 @@
import {
CircleCheck,
CircleAlert,
Loader2,
Play,
ChevronRight,
} from "lucide-react";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TestState = "idle" | "running" | "pass" | "fail";
const copy = {
idle: {
title: "Test your agent",
description: "Check that your runtime and model can respond.",
action: "Run test",
},
running: {
title: "Testing connection",
description: "Checking the runtime and waiting for a model response…",
action: "Testing…",
},
pass: {
title: "Connection successful",
description: "Your runtime checks passed. Review the details below.",
action: "Test again",
},
warn: {
title: "Connection needs attention",
description: "Review the test details before running your agent.",
action: "Test again",
},
fail: {
title: "Couldn't connect",
description: "Check your model and provider connection, then try again.",
action: "Retry test",
},
} as const;
export function RuntimeTestCard({
state,
result,
error,
onTest,
disabled = false,
}: {
state: TestState | "warn";
result: AdapterEnvironmentTestResult | null;
error?: string | null;
onTest: () => void;
disabled?: boolean;
}) {
const content = copy[state];
const Icon =
state === "running"
? Loader2
: state === "pass"
? CircleCheck
: state === "fail" || state === "warn"
? CircleAlert
: Play;
return (
<section
aria-label="Runtime test"
className="rounded-lg border border-border bg-card"
>
<div className="flex items-start gap-3 p-4 sm:items-center">
<span
aria-hidden="true"
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full bg-muted",
state === "pass"
? "text-green-600 dark:text-green-400"
: state === "fail"
? "text-destructive"
: "text-muted-foreground",
)}
>
<Icon
className={cn(
"size-4",
state === "running" && "animate-spin motion-reduce:animate-none",
)}
/>
</span>
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
role={state === "fail" ? "alert" : "status"}
aria-atomic="true"
className="min-w-0 space-y-1"
>
<h3 className="text-sm font-medium">{content.title}</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{state === "fail" && error ? error : content.description}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 self-start sm:self-center"
disabled={disabled || state === "running"}
onClick={onTest}
>
{content.action}
</Button>
</div>
</div>
{result && result.checks.length > 0 && (
<details
key={`${state}:${result.testedAt}`}
className="group border-t border-border"
>
<summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 py-2.5 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
Test details
</summary>
<ul className="space-y-3 px-4 pb-4">
{result.checks.map((check) => (
<li
key={check.code}
className="flex gap-2 text-xs leading-relaxed"
>
{check.level === "error" || check.level === "warn" ? (
<CircleAlert
aria-hidden="true"
className="mt-0.5 size-3.5 shrink-0 text-destructive"
/>
) : (
<CircleCheck
aria-hidden="true"
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
/>
)}
<div className="min-w-0 space-y-1">
<p className="break-words text-foreground">{check.message}</p>
{check.detail && (
<p className="break-all text-muted-foreground">
{check.detail}
</p>
)}
{check.hint && (
<p className="text-muted-foreground">{check.hint}</p>
)}
</div>
</li>
))}
</ul>
</details>
)}
</section>
);
}
+94
View File
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ScrollToBottom } from "./ScrollToBottom";
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: true }),
}));
vi.mock("../context/PanelContext", () => ({
usePanel: () => ({ panelVisible: false, panelContent: null }),
}));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
describe("ScrollToBottom mobile composer docking", () => {
let host: HTMLDivElement;
let main: HTMLElement;
let dock: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.defineProperty(document.documentElement, "scrollHeight", {
configurable: true,
value: 1000,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 500,
});
Object.defineProperty(window, "scrollY", {
configurable: true,
value: 0,
});
main = document.createElement("main");
main.id = "main-content";
dock = document.createElement("div");
dock.dataset.testid = "task-chat-composer-dock";
main.appendChild(dock);
document.body.appendChild(main);
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
document.body.replaceChildren();
vi.restoreAllMocks();
});
it("moves with the active mobile composer dock", async () => {
await act(async () => {
root.render(<ScrollToBottom />);
});
const initialButton = dock.querySelector<HTMLButtonElement>(
'button[aria-label="Scroll to bottom"]',
);
expect(initialButton).not.toBeNull();
expect(initialButton?.classList).toContain("absolute");
expect(host.querySelector('button[aria-label="Scroll to bottom"]')).toBeNull();
await act(async () => {
dock.remove();
await Promise.resolve();
});
const fallbackButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="Scroll to bottom"]',
);
expect(fallbackButton).not.toBeNull();
expect(fallbackButton?.classList).toContain("fixed");
const replacementDock = document.createElement("div");
replacementDock.dataset.testid = "task-chat-composer-dock";
await act(async () => {
main.appendChild(replacementDock);
await Promise.resolve();
});
const replacementButton = replacementDock.querySelector<HTMLButtonElement>(
'button[aria-label="Scroll to bottom"]',
);
expect(replacementButton).not.toBeNull();
expect(replacementButton?.classList).toContain("absolute");
expect(host.querySelector('button[aria-label="Scroll to bottom"]')).toBeNull();
});
});
+29 -3
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
import { createPortal } from "react-dom";
import { useSidebar } from "../context/SidebarContext";
import { usePanel } from "../context/PanelContext";
import { cn } from "../lib/utils";
@@ -36,6 +38,24 @@ function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
export function ScrollToBottom() {
const [visible, setVisible] = useState(false);
const { panelVisible, panelContent } = usePanel();
const { isMobile } = useSidebar();
const [composerDock, setComposerDock] = useState<HTMLElement | null>(null);
useEffect(() => {
if (!isMobile) {
setComposerDock(null);
return;
}
const main = document.getElementById("main-content");
if (!main) return;
const findDock = () => setComposerDock(
main.querySelector<HTMLElement>('[data-testid="task-chat-composer-dock"]'),
);
findDock();
const observer = new MutationObserver(findDock);
observer.observe(main, { childList: true, subtree: true });
return () => observer.disconnect();
}, [isMobile]);
useEffect(() => {
const check = () => {
@@ -70,16 +90,22 @@ export function ScrollToBottom() {
if (!visible) return null;
return (
const button = (
<button
data-slot="icon-button"
onClick={scroll}
className={cn(
"fixed bottom-(--sz-calc-21) right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-(--tp-background-color-right) duration-200 md:bottom-6",
panelVisible && panelContent && "md:right-(--sz-calc-22)",
"z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-(--tp-background-color-right) duration-200",
isMobile && composerDock
? "absolute bottom-full left-1/2 -translate-x-1/2 mb-3"
: "fixed bottom-(--sz-calc-21) right-6 md:bottom-6",
!isMobile && panelVisible && panelContent && "md:right-(--sz-calc-22)",
)}
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
);
// Anchoring to the dock follows editor growth and the nav's sticky offset.
return isMobile && composerDock ? createPortal(button, composerDock) : button;
}
+655 -8
View File
@@ -6,6 +6,7 @@ import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TaskChatThread } from "./TaskChatThread";
import type {
IssueDocument,
@@ -13,6 +14,8 @@ import type {
IssueThreadInteraction,
} from "@paperclipai/shared";
import { heartbeatsApi } from "@/api/heartbeats";
import { nativeRunEventsToTranscript } from "./transcript/native-run-events";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
const transcriptState = vi.hoisted(() => ({
transcriptByRun: new Map(),
@@ -97,6 +100,7 @@ vi.mock("@/components/MarkdownEditor", () => ({
let container: HTMLDivElement;
let root: Root | null = null;
let queryClient: QueryClient;
beforeEach(() => {
localStorage.clear();
@@ -112,10 +116,17 @@ beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
});
afterEach(() => {
flushSync(() => root?.unmount());
queryClient.clear();
root = null;
container.remove();
localStorage.clear();
@@ -123,9 +134,71 @@ afterEach(() => {
});
function render(ui: ReactElement) {
flushSync(() => root!.render(<ThemeProvider>{ui}</ThemeProvider>));
flushSync(() =>
root!.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>{ui}</ThemeProvider>
</QueryClientProvider>,
),
);
}
it("coordinates first reveal while keeping the composer and visible history mounted through refresh", async () => {
const props = {
issueId: "coordinated-issue",
comments: [],
onAdd: async () => {},
};
render(<TaskChatThread {...props} initialHistoryPending />);
const composer = container.querySelector('[data-testid="mock-editor"]');
expect(composer).not.toBeNull();
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
render(<TaskChatThread {...props} initialHistoryPending={false} />);
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(container.querySelector('[aria-busy="false"]')).not.toBeNull();
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
render(<TaskChatThread {...props} initialHistoryPending />);
expect(
container.querySelector('[data-testid="task-chat-history-loading"]'),
).toBeNull();
expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer);
});
it("keeps an acknowledged optimistic bubble mounted with its canonical comment target", () => {
const comment = {
companyId: "company",
issueId: "issue",
authorAgentId: null,
presentation: null,
metadata: null,
updatedAt: new Date("2026-09-09T12:00:00Z"),
id: "optimistic-one",
clientId: "optimistic-one",
body: "Keep this message in place",
authorType: "user" as const,
authorUserId: "board",
createdAt: new Date("2026-09-09T12:00:00Z"),
};
render(<TaskChatThread comments={[comment]} onAdd={async () => {}} />);
const row = container.querySelector('[data-thread-anchor="optimistic-one"]');
expect(row).not.toBeNull();
render(
<TaskChatThread
comments={[{ ...comment, id: "canonical-one" }]}
onAdd={async () => {}}
/>,
);
expect(container.querySelector('[data-thread-anchor="optimistic-one"]')).toBe(
row,
);
expect(row?.id).toBe("comment-canonical-one");
expect(
container.textContent?.match(/Keep this message in place/g),
).toHaveLength(1);
});
function fakeScrollGeometry(
element: HTMLElement,
{ scrollHeight = 1000, clientHeight = 400, scrollTop = 600 } = {},
@@ -322,7 +395,7 @@ describe("TaskChatThread draft pass-through", () => {
expect(scroller?.firstElementChild?.classList).toContain("pt-3");
});
it("keeps the composer dock aligned with the thread's horizontal padding", () => {
it("lets the mobile composer dock use the full thread width", () => {
render(
<TaskChatThread
comments={[
@@ -347,12 +420,11 @@ describe("TaskChatThread draft pass-through", () => {
const dock = container.querySelector(
'[data-testid="task-chat-composer-dock"]',
);
const thread = container.querySelector(
'[data-testid="task-chat-thread"]',
);
const thread = container.querySelector('[data-testid="task-chat-thread"]');
expect(thread?.classList).not.toContain("h-(--tc-thread-max-h)");
expect(thread?.classList).toContain("flex-1");
expect(dock?.classList).toContain("px-4");
expect(dock?.classList).toContain("px-2");
expect(dock?.classList).toContain("md:px-0");
expect(dock?.classList).not.toContain("px-1");
expect(dock?.classList).not.toContain("-mt-(--radius-task-composer)");
expect(dock?.classList).not.toContain("pt-1");
@@ -984,6 +1056,88 @@ describe("TaskChatThread runtime transcript selection", () => {
expect(onRetryFailedRun).toHaveBeenCalledWith("native-failed");
});
it("explains a native provider usage limit without exposing its error code", async () => {
const onRetryFailedRun = vi.fn();
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="blocked"
onRetryFailedRun={onRetryFailedRun}
linkedRuns={[
{
runId: "native-usage-limit",
runtimeMode: "native",
status: "failed",
errorCode: "native_provider_usage_limit",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-08-25T18:00:00.000Z",
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: "2026-08-25T18:00:02.000Z",
},
]}
/>,
);
const marker = container.querySelector(
'[data-testid="task-chat-collapsible-marker"]',
);
expect(marker?.textContent).toContain("Usage limit reached");
expect(marker?.textContent).not.toContain("Run failed");
const disclosure = marker?.querySelector<HTMLButtonElement>(
'button[aria-expanded="false"]',
);
expect(disclosure).not.toBeNull();
flushSync(() => disclosure!.click());
const details = container.querySelector(
'[data-testid="task-chat-collapsible-marker-details"]',
);
expect(details?.textContent).toContain(
"The model provider has reached its current usage limit. Try again after the limit resets.",
);
expect(details?.textContent).not.toContain("native_provider_usage_limit");
const retry = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-run-failed-try-again"]',
);
expect(retry).not.toBeNull();
flushSync(() => retry!.click());
await Promise.resolve();
expect(onRetryFailedRun).toHaveBeenCalledWith("native-usage-limit");
});
it("explains a legacy run prevented from starting by a reconciliation hold", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[{
runId: "blocked-legacy", runtimeMode: "legacy", status: "cancelled",
errorCode: "execution_reconciliation_required", agentId: "agent-1", agentName: "Runner",
adapterType: "claude_local", createdAt: "2026-08-25T18:00:00.000Z",
startedAt: null, finishedAt: "2026-08-25T18:00:00.012Z",
}]} />);
expect(container.textContent).toContain("Waiting to resume");
expect(container.textContent).not.toContain("No user-facing response");
expect(container.textContent).not.toContain("Run completed");
expect(container.querySelector(".text-destructive")).toBeNull();
});
it.each(["legacy", "native"] as const)("groups repeated %s pre-start holds without hiding executed work", (runtimeMode) => {
const heldRun = (id: string, recoveryActionId: string, startedAt: string | null = null) => ({
runId: id, runtimeMode, status: "cancelled", errorCode: "execution_reconciliation_required",
agentId: "agent-1", adapterType: runtimeMode === "native" ? "paperclip_runner" : "claude_local",
createdAt: "2026-09-10T18:00:00.000Z", finishedAt: "2026-09-10T18:00:01.000Z", startedAt,
resultJson: { executionWait: { recoveryActionId } },
});
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[
...Array.from({ length: 100 }, (_, i) => heldRun(`wait-${i}`, "hold-1")),
heldRun("ran", "hold-1", "2026-09-10T18:00:00.500Z"),
heldRun("new-hold", "hold-2"),
heldRun("same-new-hold", "hold-2"),
]} />);
expect(container.textContent?.match(/Waiting to resume/g)).toHaveLength(2);
expect(container.textContent).not.toContain("Couldn't start");
expect(container.textContent).toContain(runtimeMode === "native" ? "Run cancelled" : "Stopped");
});
it("shows cancellation after native progress without offering a retry", () => {
nativeTranscriptState.transcriptByRun.set("native-cancelled", [
{
@@ -1015,6 +1169,8 @@ describe("TaskChatThread runtime transcript selection", () => {
);
expect(container.textContent).toContain("Work was in progress.");
expect(container.querySelector('[data-testid="task-chat-collapsible-marker"] button')?.classList).toContain("text-muted-foreground");
expect(container.querySelector('[data-testid="task-chat-collapsible-marker"] .text-destructive')).toBeNull();
expect(
container.querySelector('[data-testid="task-chat-collapsible-marker"]')
?.textContent,
@@ -1091,6 +1247,73 @@ describe("TaskChatThread runtime transcript selection", () => {
},
);
it.each([
["legacy", "issue_not_in_progress"],
["native", "issue_not_in_progress"],
["legacy", "issue_terminal_status"],
["native", "issue_terminal_status"],
] as const)(
"hides a redundant cancelled continuation (%s, %s)",
(runtimeMode, errorCode) => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
linkedRuns={[
{
runId: "connection-continuation-skipped",
runtimeMode,
status: "cancelled",
errorCode,
startedAt: null,
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-09-07T18:00:00.000Z",
finishedAt: "2026-09-07T18:00:01.000Z",
},
]}
/>,
);
expect(container.textContent).not.toContain(
"The runner returned no user-facing response.",
);
expect(container.textContent).not.toContain("Run completed");
expect(container.textContent).not.toContain("Couldn't start");
expect(container.textContent).not.toContain("Run cancelled");
expect(container.textContent).not.toContain("before returning an answer");
},
);
it.each(["legacy", "native"] as const)(
"keeps a cancellation visible when the %s run had already started",
(runtimeMode) => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
linkedRuns={[
{
runId: "started-cancellation",
runtimeMode,
status: "cancelled",
errorCode: "issue_terminal_status",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-09-07T18:00:00.000Z",
startedAt: "2026-09-07T18:00:00.500Z",
finishedAt: "2026-09-07T18:00:01.000Z",
},
]}
/>,
);
expect(container.textContent).toContain(
runtimeMode === "native" ? "Run cancelled" : "Stopped",
);
},
);
it("does not treat a progress comment as the final response of a failed native run", () => {
nativeTranscriptState.transcriptByRun.set("native-progress-failed", [
{
@@ -1411,6 +1634,238 @@ describe("TaskChatThread runtime transcript selection", () => {
);
});
it.each([
"missing_result_metadata",
"yielded_result_metadata",
"steered_missing_result_metadata",
"persisted_response_comment",
"pending_attention",
"live",
"failed",
"proposed",
"question",
"approval",
])("renders only an authorized terminal response-wake answer: %s", (mode) => {
const runId = "native-checklist-response";
const summary =
"**Before release**\n- Test and rehearse rollback.\n\n**During release**\n- Deploy incrementally and watch errors.\n\n**After release**\n- Verify workflows and record follow-ups.";
const result = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "yielded",
summary,
completionClaim: { objectiveSatisfied: true, remainingWork: [] },
continuation: {
kind: "response_wake",
idempotencyKey: "next-chat-input",
},
attentionRequests:
mode === "question" || mode === "approval"
? [
{
kind:
mode === "question"
? "ask_user_questions"
: "request_confirmation",
},
]
: [],
evidence: [],
verification: [],
artifacts: [],
};
const event = (
seq: number,
eventType: string,
payload: Record<string, unknown>,
) =>
({
id: seq,
companyId: "company-1",
agentId: "agent-1",
stream: "system",
level: "info",
color: null,
message: null,
runId,
seq,
eventType,
createdAt: new Date("2026-08-25T18:00:00Z"),
payload: {
prpEvent: {
schema: "paperclip.prp.event.v1",
schemaVersion: 1,
runId,
eventType,
sourceEventId: `checklist-${seq}`,
sourceKind:
eventType === "run.result.accepted" ||
eventType === "run.terminal"
? "control_plane"
: "runner",
sourceInstanceId: "runner-1",
sourceSeq: seq,
normalizedSessionId: "session-1",
emittedAt: `2026-08-25T18:00:0${seq}Z`,
payload,
},
},
}) satisfies HeartbeatRunEvent;
nativeTranscriptState.transcriptByRun.set(
runId,
nativeRunEventsToTranscript([
event(1, "item.completed", {
kind: "agentMessage",
channel: "progress",
item: {
id: "preamble",
type: "agentMessage",
channel: "progress",
text: "I’m providing the requested checklist and leaving the task open.",
},
}),
event(2, "run.result.proposed", result),
...(mode === "proposed"
? []
: [event(3, "run.result.accepted", { result })]),
event(4, "run.terminal", {
schema: "paperclip.prp.terminal.v1",
turnTerminalState: "completed",
runTerminalState: "succeeded",
reportedWorkDisposition: "yielded",
}),
]),
);
render(
<TaskChatThread
comments={
mode === "steered_missing_result_metadata"
? [
{
id: "response-wake-steering",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "Keep the task open for follow-up.",
presentation: null,
metadata: null,
runId: null,
followUpRequested: true,
consumedByRunId: runId,
steeredIntoRunId: runId,
conversationAnchorAt: new Date("2026-08-25T18:00:03.500Z"),
createdAt: new Date("2026-08-25T18:00:03.000Z"),
updatedAt: new Date("2026-08-25T18:00:03.500Z"),
},
]
: mode === "persisted_response_comment"
? [
{
id: "private-board-response",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
body: summary,
presentation: null,
metadata: null,
createdByRunId: runId,
runId,
createdAt: new Date("2026-08-25T18:00:29.000Z"),
updatedAt: new Date("2026-08-25T18:00:29.000Z"),
},
]
: []
}
onAdd={async () => {}}
issueStatus="in_progress"
interactions={
mode === "pending_attention"
? [planReviewInteraction("pending", "revision-3", runId)]
: []
}
activeRun={
mode === "live"
? {
id: runId,
runtimeMode: "native",
status: "running",
invocationSource: "issue",
triggerDetail: null,
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-08-25T18:00:00Z",
startedAt: "2026-08-25T18:00:00Z",
finishedAt: null,
}
: undefined
}
linkedRuns={[
{
runId,
runtimeMode: "native",
status:
mode === "live"
? "running"
: mode === "failed"
? "failed"
: "succeeded",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-08-25T18:00:00Z",
startedAt: "2026-08-25T18:00:00Z",
finishedAt: mode === "live" ? null : "2026-08-25T18:00:30Z",
resultJson: mode.endsWith("missing_result_metadata")
? { recoveredExecutionFailure: { errorCode: "adapter_failed" } }
: {
nativeResult: result,
...(mode === "persisted_response_comment"
? {
presentationDecision: {
schema: "paperclip.run_presentation_decision.v1",
chosenSource: "existing_issue_comment",
commentId: "private-board-response",
},
}
: {}),
},
},
]}
/>,
);
const responses = container.querySelectorAll(
'[data-testid="task-chat-final-response"]',
);
if (mode === "persisted_response_comment") {
expect(responses).toHaveLength(0);
expect(container.textContent?.split("Before release")).toHaveLength(2);
expect(
container.querySelector('[data-testid="task-chat-agent-bubble"]')
?.textContent,
).toContain("After release");
return;
}
if (
mode !== "missing_result_metadata" &&
mode !== "yielded_result_metadata" &&
mode !== "steered_missing_result_metadata"
) {
expect(responses).toHaveLength(0);
return;
}
expect(responses).toHaveLength(1);
expect(responses[0]?.textContent).toContain("Before release");
expect(responses[0]?.textContent).toContain("After release");
expect(responses[0]?.textContent).not.toContain("I’m providing");
expect(container.textContent).not.toContain(
"The runner returned no user-facing response.",
);
});
it("keeps a yielded question turn out of the final-response slot", () => {
nativeTranscriptState.transcriptByRun.set("native-question", [
{
@@ -1781,8 +2236,8 @@ describe("TaskChatThread composer alignment", () => {
expect(dock?.classList).not.toContain("-mt-(--radius-task-composer)");
expect(composer?.classList).not.toContain("border");
expect(composer?.classList).toContain("bg-card");
expect(send?.classList).toContain("rounded-md");
expect(send?.classList).not.toContain("rounded-full");
expect(send?.classList).toContain("rounded-full");
expect(send?.classList).not.toContain("rounded-md");
});
});
@@ -2346,6 +2801,41 @@ describe("TaskChatThread Paperclip Runner queue", () => {
return container.textContent?.split(text).length! - 1;
}
it("hides queued actions while paused and restores the queue after resume", () => {
const onSteerQueuedComment = vi.fn(async () => {});
const props = {
comments: [queuedComment],
onAdd: async () => {},
queuedCommentQueue: queue,
onEditQueuedComment: async () => {},
onReorderQueuedComments: async () => {},
onSteerQueuedComment,
onDiscardQueuedComment: async () => {},
};
render(<TaskChatThread {...props} />);
expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).not.toBeNull();
render(<TaskChatThread {...props} composerPause={{ scope: "leaf", onResume: () => {} }} />);
expect(container.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).toBeNull();
expect(onSteerQueuedComment).not.toHaveBeenCalled();
render(<TaskChatThread {...props} />);
expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).not.toBeNull();
expect(occurrenceCount(queuedComment.body)).toBe(1);
});
it("hides legacy transcript interrupt actions while paused", () => {
render(<TaskChatThread
comments={[{ ...queuedComment, queueState: "queued", queueTargetRunId: "run-1" }]}
onAdd={async () => {}}
onInterruptQueued={async () => {}}
composerPause={{ scope: "leaf", onResume: () => {} }}
/>);
expect(container.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull();
expect([...container.querySelectorAll("button")].some((button) => button.textContent === "Interrupt")).toBe(false);
});
it("suppresses the transcript echo until the queued entry is consumed", async () => {
const props = {
comments: [queuedComment],
@@ -2877,6 +3367,141 @@ describe("TaskChatThread live transcript", () => {
expect(container.textContent).not.toContain("no longer attached");
});
it.each(["done", "yielded"])(
"renders one native turn while its completion comment refresh is delayed: %s",
(disposition) => {
const runId = "native-delayed-comment";
const summary = "DELAYED-CANONICAL-REPLY";
nativeTranscriptState.transcriptByRun.set(runId, [
{
kind: "assistant",
ts: "2026-08-07T00:00:01Z",
text: "UNIQUE-PROGRESS-LINE",
channel: "progress",
},
{
kind: "assistant",
ts: "2026-08-07T00:00:02Z",
text: summary,
channel: "final",
},
{
kind: "run_result",
ts: "2026-08-07T00:00:03Z",
disposition,
summary,
objectiveSatisfied: true,
verification: [],
remainingWork: [],
blocker: null,
artifacts: [],
...(disposition === "yielded"
? {
acceptedResponseWake: {
runId,
sourceEventId: "accepted-result",
},
}
: {}),
},
{
kind: "run_terminal",
ts: "2026-08-07T00:00:04Z",
turnState: "completed",
runState: "succeeded",
disposition,
},
]);
const activeRun = {
id: runId,
runtimeMode: "native" as const,
status: "running",
invocationSource: "issue" as const,
triggerDetail: null,
startedAt: "2026-08-07T00:00:00Z",
createdAt: "2026-08-07T00:00:00Z",
finishedAt: null,
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
};
const linkedRun = {
...activeRun,
runId,
status: "succeeded",
finishedAt: "2026-08-07T00:00:05Z",
resultJson: {
nativeResult: {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: disposition,
summary,
},
presentationDecision: {
schema: "paperclip.run_presentation_decision.v1",
chosenSource: "existing_issue_comment",
commentId: "delayed-comment",
},
},
};
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={activeRun}
/>,
);
// The terminal run/event query wins the race, but the canonical comment
// request is still pending. The settled answer already owns the turn.
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
linkedRuns={[linkedRun]}
/>,
);
expect(container.textContent?.split("UNIQUE-PROGRESS-LINE")).toHaveLength(
2,
);
expect(container.textContent?.split(summary)).toHaveLength(2);
expect(
container.querySelector('[data-testid="task-chat-live-transcript"]'),
).toBeNull();
render(
<TaskChatThread
comments={[
{
id: "delayed-comment",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
createdByRunId: runId,
runId,
body: summary,
presentation: null,
metadata: null,
createdAt: new Date("2026-08-07T00:00:05Z"),
updatedAt: new Date("2026-08-07T00:00:05Z"),
},
]}
onAdd={async () => {}}
issueStatus="in_progress"
linkedRuns={[linkedRun]}
/>,
);
expect(container.textContent?.split("UNIQUE-PROGRESS-LINE")).toHaveLength(
2,
);
expect(container.textContent?.split(summary)).toHaveLength(2);
expect(
container.querySelector('[data-testid="task-chat-final-response"]'),
).toBeNull();
},
);
it("keeps the transcript mounted through run settle until the settled turn renders (PAP-462 B4)", () => {
transcriptState.transcriptByRun.set("run-1", [
{
@@ -2934,3 +3559,25 @@ describe("TaskChatThread live transcript", () => {
expect(container.textContent).toContain("Worked");
});
});
describe("TaskChatThread composer execution controls", () => {
it.each(["process", "paperclip_runner"])("passes the task's stop action through for %s execution", async (adapterType) => {
const onStop = vi.fn(async () => {});
const run = {
id: "task-run", status: "running", runtimeMode: adapterType === "process" ? "legacy" as const : "native" as const,
invocationSource: "issue", triggerDetail: null, startedAt: "2026-09-09T12:00:00Z", finishedAt: null,
createdAt: "2026-09-09T12:00:00Z", agentId: "agent-1", agentName: "Alex", adapterType,
};
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopScope="subtree" />);
const button = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]')!;
expect(button.title).toBe("Stop and pause subtree");
await act(async () => { button.click(); });
expect(onStop).toHaveBeenCalledOnce();
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopPending />);
expect(container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]')?.disabled).toBe(true);
});
it("does not offer Stop for settled work even when a callback is available", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="todo" onCancelRun={vi.fn()} />);
expect(container.querySelector('[data-testid="task-chat-composer-stop"]')).toBeNull();
});
});
File diff suppressed because it is too large. Load diff
+226
View File
@@ -0,0 +1,226 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { PauseCircle, PlayCircle, Repeat, XCircle } from "lucide-react";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
/** Shared by the task header and its Storybook composition. */
export function TaskTreeControlMenuItems({
scope,
canPause,
canResume,
canCancel,
canRestore,
pending,
onPause,
onResume,
onCancel,
onRestore,
}: {
scope: "leaf" | "subtree";
canPause: boolean;
canResume: boolean;
canCancel: boolean;
canRestore: boolean;
pending?: boolean;
onPause: () => void;
onResume: () => void;
onCancel: () => void;
onRestore: () => void;
}) {
const itemClass =
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 disabled:opacity-50 disabled:pointer-events-none";
return (
<>
{canPause ? (
<button disabled={pending} className={itemClass} onClick={onPause}>
<PauseCircle className="h-3 w-3" />
{scope === "leaf" ? "Pause work" : "Pause subtree"}
</button>
) : null}
{canResume ? (
<button disabled={pending} className={itemClass} onClick={onResume}>
<PlayCircle className="h-3 w-3" />
{scope === "leaf" ? "Resume work" : "Resume subtree"}
</button>
) : null}
{canCancel ? (
<button
disabled={pending}
className={`${itemClass} text-destructive`}
onClick={onCancel}
>
<XCircle className="h-3 w-3" />
Cancel subtree...
</button>
) : null}
{canRestore ? (
<button disabled={pending} className={itemClass} onClick={onRestore}>
<Repeat className="h-3 w-3" />
Restore subtree...
</button>
) : null}
</>
);
}
export function TaskTreeControlDialog({
open,
onOpenChange,
mode,
scope,
affectedCount,
affectedAgentCount,
loading,
error,
pending,
valid,
wakeAgents,
onWakeAgentsChange,
onRetry,
onApply,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "resume" | "cancel" | "restore";
scope: "leaf" | "subtree";
affectedCount: number;
affectedAgentCount: number;
loading: boolean;
error?: string | null;
pending: boolean;
valid: boolean;
wakeAgents: boolean;
onWakeAgentsChange: (wake: boolean) => void;
onRetry: () => void;
onApply: () => void;
}) {
const cancel = mode === "cancel";
const tasks = `${affectedCount} task${affectedCount === 1 ? "" : "s"}`;
const title = cancel
? "Cancel subtree?"
: mode === "restore"
? "Restore subtree"
: scope === "leaf"
? "Resume work"
: "Resume subtree";
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!pending) onOpenChange(next);
}}
>
<DialogContent
showCloseButton={!pending}
className="max-h-(--sz-calc-18) overflow-y-auto sm:max-w-sm"
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{loading
? "Loading…"
: cancel
? `${tasks} will be cancelled.`
: `${tasks} will ${mode === "restore" ? "be restored" : "resume"}.`}
</DialogDescription>
</DialogHeader>
{error ? (
<div className="space-y-2">
<p role="alert" className="text-sm text-destructive">
{error}
</p>
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={onRetry}
>
Retry preview
</Button>
</div>
) : null}
{!cancel ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={wakeAgents}
disabled={pending || loading || affectedAgentCount === 0}
onChange={(event) => onWakeAgentsChange(event.target.checked)}
/>
Wake affected agents ({affectedAgentCount})
</label>
) : null}
<DialogFooter>
<Button
variant="outline"
disabled={pending}
onClick={() => onOpenChange(false)}
>
{cancel ? "Keep tasks" : "Close"}
</Button>
<Button
variant={cancel ? "destructive" : "default"}
disabled={pending || loading || !!error || !valid}
onClick={onApply}
>
{pending
? "Applying…"
: cancel
? `Cancel ${tasks}`
: mode === "restore"
? `Restore ${tasks}`
: title}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Quiet, persistent pause feedback shared by the task page and previews. */
export function TaskPauseNotice({
scope,
onResume,
pending,
className,
resumeLink,
}: {
scope: "leaf" | "subtree";
onResume?: () => void;
pending?: boolean;
className?: string;
resumeLink?: ReactNode;
}) {
return (
<div
role="status"
className={cn(
"flex items-center justify-between gap-3 rounded-md bg-muted/50 px-3 py-2 text-sm text-muted-foreground",
className,
)}
>
<span>
{scope === "subtree" ? "Subtree is paused." : "Task is paused."}
</span>
{resumeLink ??
(onResume ? (
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={onResume}
>
{scope === "subtree" ? "Resume subtree" : "Resume work"}
</Button>
) : null)}
</div>
);
}
@@ -87,7 +87,7 @@ export function HintIcon({ text }: { text: string }) {
);
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode; configSection?: import("../adapters/types").AdapterConfigSection }) {
return (
<div>
<div className="flex items-center gap-1.5 mb-1">
@@ -200,6 +200,8 @@ export function CollapsibleSection({
return (
<div className={cn(bordered && "border-t border-border")}>
<button
type="button"
aria-expanded={open}
className="flex items-center gap-2 w-full px-4 py-2 text-xs font-medium text-muted-foreground hover:bg-accent/30 transition-colors"
onClick={onToggle}
>
@@ -0,0 +1,94 @@
import { useQuery } from "@tanstack/react-query";
import { ExternalLink, MessageSquarePlus } from "lucide-react";
import { chatEndpointsApi, type ChatProvider } from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { queryKeys } from "@/lib/queryKeys";
import { Link } from "@/lib/router";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
const providerNames: Record<ChatProvider, string> = {
slack: "Slack",
github: "GitHub",
discord: "Discord",
"microsoft-teams": "Microsoft Teams",
telegram: "Telegram",
};
export function AgentChannelsPanel({
companyId,
agentId,
}: {
companyId: string;
agentId: string;
}) {
const { enabled } = useChatConnectorsEnabled();
const query = useQuery({
queryKey: queryKeys.chatEndpoints.list(companyId),
queryFn: () => chatEndpointsApi.list(companyId),
enabled,
});
if (!enabled) return null;
const endpoints = (query.data ?? []).filter(
(endpoint) =>
endpoint.assignedAgentId === agentId && endpoint.status !== "archived",
);
return (
<section className="max-w-3xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Channels</h2>
<p className="mt-1 text-sm text-muted-foreground">
Provider identities that let people chat with this agent.
</p>
</div>
<Button asChild size="sm">
<Link to={`/apps?chatAgentId=${encodeURIComponent(agentId)}`}>
<MessageSquarePlus />
Connect a channel
</Link>
</Button>
</div>
{query.isLoading ? (
<p className="text-sm text-muted-foreground">Loading channels…</p>
) : endpoints.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-5">
<p className="text-sm font-medium">No chat channels connected</p>
<p className="mt-1 text-sm text-muted-foreground">
Connect Slack, GitHub, Discord, Microsoft Teams, or Telegram from
Connectors.
</p>
<Button asChild className="mt-3" variant="outline" size="sm">
<Link to="/apps">Open Connectors</Link>
</Button>
</div>
) : (
<div className="divide-y divide-border border-y border-border">
{endpoints.map((endpoint) => (
<div
key={endpoint.id}
className="flex flex-wrap items-center gap-3 py-4"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
{providerNames[endpoint.provider]}
</p>
<p className="truncate text-xs text-muted-foreground">
{endpoint.botLabel ??
endpoint.providerAccountLabel ??
"Provider identity"}
</p>
</div>
<StatusBadge status={endpoint.status} />
<Button asChild size="sm" variant="outline">
<Link to={`/apps/chat/${endpoint.id}/settings`}>
Open connection <ExternalLink />
</Link>
</Button>
</div>
))}
</div>
)}
</section>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,888 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, Paperclip, Radio } from "lucide-react";
import type {
ChatPublicationState,
ChatFileTransferPhase,
IssueAttachment,
} from "@paperclipai/shared";
import {
chatEndpointsApi,
type ChatProvider,
type ChatPublicationSummary,
type ExternalChannelBindingSummary,
} from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/context/ToastContext";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
import { issuesApi } from "@/api/issues";
import {
boardSendDraftKey,
clearBoardSendDraft,
canDismissBoardSendBatch,
readBoardSendDraft,
readBoardSendRejection,
writeBoardSendDraft,
type BoardSendRejection,
type RetainedBoardSend,
} from "./board-send-draft";
const providerNames: Record<ChatProvider, string> = {
slack: "Slack",
github: "GitHub",
discord: "Discord",
"microsoft-teams": "Microsoft Teams",
telegram: "Telegram",
};
type PublicationFeedback = {
title: string;
body: string;
tone: "info" | "success" | "warn" | "error";
};
const publicationFeedback: Record<ChatPublicationState, PublicationFeedback> = {
awaiting_consent: {
title: "Waiting for file consent",
body: "The recipient must accept the file card in Microsoft Teams. The file is not delivered yet; this send identity is kept while Paperclip waits.",
tone: "info",
},
published: {
title: "Sent to channel",
body: "The board update was published to the connected conversation.",
tone: "success",
},
pending: {
title: "Queued for channel",
body: "Delivery is still pending. Your draft is kept until Paperclip confirms publication.",
tone: "info",
},
streaming: {
title: "Publishing to channel",
body: "Delivery is still in progress. Your draft is kept until Paperclip confirms publication.",
tone: "info",
},
retry: {
title: "Delivery retry scheduled",
body: "Paperclip will retry this publication. Your draft and retry identity are kept.",
tone: "warn",
},
delivery_unknown: {
title: "Delivery not confirmed",
body: "The provider may have accepted this update. Resolve it in Activity before trying again to avoid a duplicate.",
tone: "warn",
},
failed: {
title: "Channel delivery failed",
body: "Your draft is kept. Open Activity to retry this same publication safely.",
tone: "error",
},
cancelled: {
title: "Channel delivery cancelled",
body: "Your draft is kept. Some parts may already have been published; check Activity before starting a new send.",
tone: "info",
},
};
const filePhaseLabels: Record<ChatFileTransferPhase, string> = {
consent_pending: "Consent card queued",
consent_sending: "Sending consent card",
consent_unknown: "Consent card delivery not confirmed",
awaiting_consent: "Awaiting consent",
upload_pending: "Upload queued",
uploading: "Uploading file",
upload_unknown: "File upload not confirmed",
file_info_pending: "File notification queued",
file_info_sending: "Sending file notification",
file_info_unknown: "File notification not confirmed",
delivered: "Delivered",
declined: "Declined",
expired: "Consent expired",
cancelled: "Cancelled; remote bytes may remain",
conflict: "File delivery needs review",
};
export function useIssueChatBinding(companyId: string, issueId: string) {
const { enabled } = useChatConnectorsEnabled();
const query = useQuery({
queryKey: ["issue-chat-binding", companyId, issueId],
queryFn: () => chatEndpointsApi.getIssueBinding(issueId),
enabled: enabled && Boolean(companyId && issueId),
});
return {
binding: enabled ? (query.data ?? null) : null,
isLoading: enabled && query.isLoading,
};
}
type ConnectedTaskProps = {
attachments?: IssueAttachment[];
companyId: string;
issueId: string;
issueCacheRefs?: string[];
};
export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) {
const { binding } = useIssueChatBinding(props.companyId, props.issueId);
if (!binding) return null;
return (
<ConnectedTaskComposer
key={boardSendDraftKey(
props.companyId,
props.issueId,
binding.endpointId,
binding.conversationId,
)}
{...props}
binding={binding}
/>
);
}
function ConnectedTaskComposer({
attachments = [],
companyId,
issueId,
issueCacheRefs,
binding,
}: ConnectedTaskProps & { binding: ExternalChannelBindingSummary }) {
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [composing, setComposing] = useState(false);
const [body, setBody] = useState("");
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<string[]>(
[],
);
const [publication, setPublication] = useState<ChatPublicationSummary | null>(
null,
);
const idempotencyKey = useRef<string | null>(null);
const retainedSend = useRef<RetainedBoardSend | null>(null);
const retainedScopeKey = useRef<string | null>(null);
const [unconfirmedRequest, setUnconfirmedRequest] = useState(false);
const [rejection, setRejection] = useState<BoardSendRejection | null>(null);
const [excludedAttachmentIds, setExcludedAttachmentIds] = useState<string[]>(
[],
);
const [selectionNotice, setSelectionNotice] = useState(false);
const [storageError, setStorageError] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const uploadInFlight = useRef(false);
const mounted = useRef(true);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadedAttachments, setUploadedAttachments] = useState<
IssueAttachment[]
>([]);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const storageKey = binding
? boardSendDraftKey(
companyId,
issueId,
binding.endpointId,
binding.conversationId,
)
: null;
const loadedStorageKey = useRef<string | null>(null);
useEffect(() => {
if (!storageKey || loadedStorageKey.current === storageKey) return;
loadedStorageKey.current = storageKey;
try {
const saved = readBoardSendDraft(storageKey);
retainedScopeKey.current = storageKey;
retainedSend.current = saved;
idempotencyKey.current = saved?.idempotencyKey ?? null;
setBody(saved?.body ?? "");
setSelectedAttachmentIds(saved?.attachmentIds ?? []);
setPublication(saved?.publication ?? null);
setUnconfirmedRequest(
Boolean(saved && !saved.publication && !saved.rejection),
);
setRejection(saved?.rejection ?? null);
setComposing(Boolean(saved));
setStorageError(null);
} catch {
setStorageError(
"Saved delivery identity could not be read. Check Activity and restore browser storage before starting another send.",
);
setComposing(true);
}
}, [storageKey]);
const deliveryScopeReady = Boolean(
storageKey &&
loadedStorageKey.current === storageKey &&
retainedScopeKey.current === storageKey,
);
const invalidateTask = useCallback(() => {
for (const ref of new Set([issueId, ...(issueCacheRefs ?? [])])) {
for (const queryKey of [
queryKeys.issues.comments(ref),
queryKeys.issues.attachments(ref),
queryKeys.issues.detail(ref),
queryKeys.issues.activity(ref),
]) {
void queryClient.invalidateQueries({ queryKey });
}
}
}, [issueId, issueCacheRefs, queryClient]);
const finishPublication = useCallback(() => {
if (storageKey) {
try {
clearBoardSendDraft(storageKey);
} catch {
/* The retained anchor remains safe to recheck after reload. */
}
}
retainedSend.current = null;
setUnconfirmedRequest(false);
setRejection(null);
setSelectionNotice(false);
setPublication(null);
idempotencyKey.current = null;
setBody("");
setSelectedAttachmentIds([]);
setUploadedAttachments([]);
setUploadError(null);
setComposing(false);
invalidateTask();
pushToast(publicationFeedback.published);
}, [invalidateTask, pushToast, storageKey]);
// Keep the first returned ID as the anchor. A batch's blocking row may
// change as text and files finish; no read is allowed to submit another send.
const publicationStatus = useQuery({
queryKey: [
"chat-publication-batch",
companyId,
binding?.endpointId,
binding?.conversationId,
publication?.id,
],
queryFn: () =>
chatEndpointsApi.getPublicationBatchStatus(
binding!.endpointId,
binding!.conversationId,
publication!.id,
),
enabled: deliveryScopeReady && Boolean(publication),
staleTime: 0,
refetchInterval: 2_000,
refetchIntervalInBackground: false,
retry: false,
});
useEffect(() => {
const batch = publicationStatus.data;
if (
publication &&
batch &&
batch.total > 0 &&
batch.published === batch.total &&
batch.publication.state === "published"
) {
finishPublication();
}
}, [publication, publicationStatus.data, finishPublication]);
const publish = useMutation({
mutationFn: (input: {
attachmentIds: string[];
body: string;
idempotencyKey: string;
endpointId: string;
conversationId: string;
}) =>
chatEndpointsApi.publishBoardMessage(
input.endpointId,
input.conversationId,
input.body,
input.idempotencyKey,
input.attachmentIds,
),
onSuccess: (result) => {
invalidateTask();
const feedback = publicationFeedback[result.state];
setPublication(result.state === "published" ? null : result);
if (result.state === "published") {
finishPublication();
return;
}
setUnconfirmedRequest(false);
if (storageKey && retainedSend.current) {
retainedSend.current = {
...retainedSend.current,
publication: {
id: result.id,
state: result.state,
attempts: result.attempts,
},
};
try {
writeBoardSendDraft(storageKey, retainedSend.current);
} catch {
// The pre-POST payload/key is already persisted. It remains a safe,
// explicit same-request retry when the publication ID cannot be saved.
}
}
pushToast({
...feedback,
action: {
label: "View activity",
href: `/apps/chat/${binding!.endpointId}/activity`,
},
});
},
onError: (error, request) => {
const rejected = readBoardSendRejection(error, request);
if (rejected && retainedSend.current && storageKey) {
const saved = { ...retainedSend.current, rejection: rejected };
try {
// Keep the negative receipt through reload before offering a new key.
writeBoardSendDraft(storageKey, saved);
} catch {
setStorageError(
"The rejected send could not be saved. Restore browser storage, then retry this same request to recover its receipt.",
);
return;
}
retainedSend.current = saved;
setRejection(rejected);
setUnconfirmedRequest(false);
invalidateTask();
pushToast({
title: "Update was not sent",
body: "A selected file already belongs to another comment. Edit the rejected send to correct the selection.",
tone: "error",
});
return;
}
pushToast({
title: "Couldn't confirm channel delivery",
body:
error instanceof Error
? `${error.message} Your draft is kept; retrying here reuses the same request identity.`
: "Your draft is kept; retrying here reuses the same request identity.",
tone: "error",
});
},
});
const uploadDisabled = Boolean(
retainedSend.current ||
publication ||
publish.isPending ||
publish.isError ||
unconfirmedRequest ||
storageError ||
!deliveryScopeReady ||
uploading,
);
async function uploadFile(file: File) {
if (uploadDisabled || uploadInFlight.current || retainedSend.current)
return;
uploadInFlight.current = true;
setUploading(true);
setUploadError(null);
try {
const attachment = await issuesApi.uploadAttachment(
companyId,
issueId,
file,
);
if (!mounted.current) return;
setUploadedAttachments((current) => [...current, attachment]);
setSelectedAttachmentIds((current) => [...current, attachment.id]);
idempotencyKey.current = null;
} catch (error) {
if (mounted.current) {
setUploadError(
`${error instanceof Error ? error.message : "Upload could not be confirmed."} No channel message was sent. Check task files before retrying the upload.`,
);
}
} finally {
uploadInFlight.current = false;
if (mounted.current) setUploading(false);
// An interrupted response may still have stored the file on this task.
invalidateTask();
}
}
// Keep newly uploaded files usable before the task refetch completes. Once
// present, server metadata wins (especially a file bound to a sent comment).
const taskAttachments = [
...new Map(
[...uploadedAttachments, ...attachments].map((attachment) => [
attachment.id,
attachment,
]),
).values(),
];
useEffect(() => {
// Metadata may arrive after this file was selected but before Send. Never
// silently keep a now-hidden selection, and never rewrite a retained send.
if (retainedSend.current) return;
const newlyBound = attachments
.filter((file) => file.issueCommentId !== null)
.map((file) => file.id);
if (!selectedAttachmentIds.some((id) => newlyBound.includes(id))) return;
setSelectedAttachmentIds((current) =>
current.filter((id) => !newlyBound.includes(id)),
);
setSelectionNotice(true);
idempotencyKey.current = null;
}, [attachments, selectedAttachmentIds]);
const showingRetainedFiles = Boolean(retainedSend.current);
// Comment binding removes files from new-send eligibility, not from the
// immutable receipt for the current send. Saved names survive reload while
// task metadata is loading (or a selected attachment has since been removed).
const visibleAttachments = retainedSend.current
? retainedSend.current.attachmentIds.map((id) => ({
id,
originalFilename:
retainedSend.current?.attachmentNames?.find((file) => file.id === id)
?.name ??
taskAttachments.find((attachment) => attachment.id === id)
?.originalFilename ??
"Selected task file (details unavailable)",
}))
: taskAttachments.filter(
(attachment) =>
attachment.issueCommentId === null &&
!excludedAttachmentIds.includes(attachment.id),
);
const currentPublication = publicationStatus.data?.publication ?? publication;
const batch = publicationStatus.data;
const dismissible =
!publicationStatus.isError &&
!publicationStatus.isFetching &&
canDismissBoardSendBatch(batch);
const mixedTerminal =
canDismissBoardSendBatch(batch) && batch!.published < batch!.total;
const currentFeedback = mixedTerminal
? {
title: "Delivery settled with mixed outcomes",
body: "Not every part was confirmed delivered. Review the outcomes below; dismissing this receipt does not resend anything.",
tone: "info" as const,
}
: currentPublication?.state === "cancelled" &&
(batch?.awaitingConsent ?? 0) > 0
? {
title: "Waiting for remaining file consent",
body: "Some parts have settled. The remaining file cards still need the recipient's response; this send stays locked until the whole batch is resolved.",
tone: "info" as const,
}
: currentPublication
? publicationFeedback[currentPublication.state]
: null;
const activityPath = `/apps/chat/${binding.endpointId}/activity`;
return (
<section
aria-label="External conversation"
className="space-y-3 rounded-lg border border-border bg-muted/40 p-3 text-sm"
>
<div className="flex flex-wrap items-center gap-3">
<div className="flex min-w-0 flex-1 basis-64 items-center gap-3">
<Radio className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="font-medium">
Connected to {providerNames[binding.provider]}
</p>
<p className="truncate text-xs text-muted-foreground">
{binding.externalLabel} · Agent assignment is fixed for this
external task.
</p>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{binding.externalUrl && (
<Button asChild size="sm" variant="outline">
<a href={binding.externalUrl} target="_blank" rel="noreferrer">
Open {providerNames[binding.provider]} <ExternalLink />
</a>
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => setComposing((value) => !value)}
>
Send to channel
</Button>
<Button asChild size="sm" variant="ghost">
<Link to={`/apps/chat/${binding.endpointId}/conversations`}>
Connection
</Link>
</Button>
</div>
</div>
{composing && (
<div className="space-y-2 border-t border-border pt-3">
<label
className="text-xs font-medium"
htmlFor="external-board-update"
>
Board update
</label>
<Textarea
id="external-board-update"
value={body}
disabled={
Boolean(publication) ||
Boolean(rejection) ||
publish.isError ||
unconfirmedRequest ||
Boolean(storageError) ||
!deliveryScopeReady
}
onChange={(event) => {
setBody(event.target.value);
idempotencyKey.current = null;
publish.reset();
}}
placeholder="Write only what should be visible in the provider conversation."
/>
{selectedAttachmentIds.length > 0 && !body.trim() && (
<p className="text-xs text-muted-foreground">
Add a message to send with your files.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInput}
type="file"
className="hidden"
aria-label="Attach file to channel update"
disabled={uploadDisabled}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void uploadFile(file);
}}
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={uploadDisabled}
onClick={() => fileInput.current?.click()}
>
<Paperclip />
{uploading ? "Uploading…" : "Attach file"}
</Button>
<p className="text-xs text-muted-foreground">
Files stay on this task until you send them to the channel.
</p>
</div>
{uploadError && (
<p role="alert" className="text-xs text-destructive">
{uploadError}
</p>
)}
{selectionNotice && (
<p role="status" className="text-xs text-muted-foreground">
A file already attached to another comment was removed from this
selection. Attach a new copy or share the task link; your message
is unchanged.
</p>
)}
{visibleAttachments.length > 0 && (
<fieldset
className="space-y-2 rounded-md border border-border bg-background p-3"
disabled={
showingRetainedFiles ||
Boolean(publication) ||
publish.isError ||
unconfirmedRequest ||
Boolean(storageError) ||
!deliveryScopeReady
}
>
<legend className="px-1 text-xs font-medium">
{showingRetainedFiles
? "Files in this send"
: "Include task files"}
</legend>
<p className="text-xs text-muted-foreground">
{binding.provider === "github"
? "GitHub Apps cannot upload file bytes in comments. Checked files stay on the Paperclip task; GitHub receives an authenticated task link when this Board has a public URL, or a private-task notice otherwise."
: binding.provider === "microsoft-teams" &&
!showingRetainedFiles
? "In personal Teams chats, recipients accept each file before upload. Channels and group chats receive supported images directly; other files stay on the task, with a task link or private-task notice."
: showingRetainedFiles
? "These are the files selected for this send. Selection is locked until delivery is resolved."
: "Only checked files will be published to the external conversation."}
</p>
<div className="space-y-2">
{visibleAttachments.map((attachment) => {
const label =
attachment.originalFilename ?? "Unnamed attachment";
return (
<label
className="flex items-center gap-2 text-xs"
key={attachment.id}
>
<Checkbox
disabled={showingRetainedFiles}
checked={selectedAttachmentIds.includes(attachment.id)}
onCheckedChange={(checked) => {
setSelectedAttachmentIds((current) =>
checked === true
? [...current, attachment.id]
: current.filter((id) => id !== attachment.id),
);
idempotencyKey.current = null;
publish.reset();
}}
/>
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{label}</span>
</label>
);
})}
</div>
</fieldset>
)}
{storageError && (
<p role="alert" className="text-xs text-destructive">
{storageError}
</p>
)}
{rejection && (
<div
role="alert"
className="space-y-1 rounded-md border border-border bg-background p-3 text-xs"
>
<p className="font-medium">Update was not sent</p>
<p className="text-muted-foreground">
A selected file already belongs to another comment. This request
was rejected before any channel message was queued. Your exact
draft is kept.
</p>
<Button
size="sm"
variant="outline"
disabled={Boolean(storageError)}
onClick={() => {
if (!storageKey || !retainedSend.current?.rejection) return;
try {
clearBoardSendDraft(storageKey);
} catch {
setStorageError(
"Saved rejection could not be cleared. Restore browser storage before editing this send.",
);
return;
}
const invalidIds =
retainedSend.current.rejection.attachmentIds;
setExcludedAttachmentIds((current) => [
...new Set([...current, ...invalidIds]),
]);
setSelectedAttachmentIds((current) =>
current.filter((id) => !invalidIds.includes(id)),
);
setUploadedAttachments((current) =>
current.filter((file) => !invalidIds.includes(file.id)),
);
retainedSend.current = null;
idempotencyKey.current = null;
setRejection(null);
setUnconfirmedRequest(false);
setSelectionNotice(true);
publish.reset();
}}
>
Edit rejected send
</Button>
</div>
)}
{!rejection &&
(publish.isError || unconfirmedRequest) &&
!publish.isPending &&
!publication && (
<div
role="alert"
className="space-y-1 rounded-md border border-border bg-background p-3 text-xs"
>
<p className="font-medium">Delivery result not confirmed</p>
<p className="text-muted-foreground">
Your exact draft and request identity are kept. Retry safely
to learn the authoritative publication state without creating
a duplicate.
</p>
<Link
className="inline-block font-medium underline underline-offset-4"
to={activityPath}
>
Open Activity
</Link>
</div>
)}
{publication && currentPublication && currentFeedback && (
<div
role={
currentPublication.state === "failed" ||
currentPublication.state === "delivery_unknown"
? "alert"
: "status"
}
className="space-y-1 rounded-md border border-border bg-background p-3 text-xs"
>
<p className="font-medium">{currentFeedback.title}</p>
<p className="text-muted-foreground">{currentFeedback.body}</p>
{batch && (
<p className="text-muted-foreground">
{batch.declined !== undefined &&
batch.expired !== undefined &&
batch.cancelled !== undefined &&
batch.awaitingConsent !== undefined
? [
`${batch.published} published`,
...(batch.awaitingConsent
? [`${batch.awaitingConsent} awaiting consent`]
: []),
...(batch.declined
? [`${batch.declined} declined`]
: []),
...(batch.expired ? [`${batch.expired} expired`] : []),
...(batch.cancelled
? [`${batch.cancelled} cancelled`]
: []),
].join(" · ")
: `${batch.published} of ${batch.total} parts published.`}
</p>
)}
{batch?.parts?.some((part) => part.fileTransfer) && (
<ul
className="space-y-1 text-muted-foreground"
aria-label="File delivery outcomes"
>
{batch.parts
.filter((part) => part.fileTransfer)
.map((part) => (
<li key={part.id}>
{part.fileTransfer!.filename} —{" "}
{filePhaseLabels[part.fileTransfer!.phase]}
</li>
))}
</ul>
)}
{publicationStatus.isError && (
<p role="alert" className="text-muted-foreground">
Delivery status could not be refreshed. Your draft is kept;
Paperclip will check again without sending another update.
</p>
)}
{currentPublication.redactedError && (
<p className="text-muted-foreground">
Provider detail: {currentPublication.redactedError}
</p>
)}
<Link
className="inline-block font-medium underline underline-offset-4"
to={activityPath}
>
Open Activity
</Link>
{dismissible && (
<Button
className="ml-3"
size="sm"
variant="ghost"
onClick={() => {
if (storageKey) {
try {
clearBoardSendDraft(storageKey);
} catch {
setStorageError(
"Saved delivery identity could not be cleared. Restore browser storage before starting another send.",
);
return;
}
}
setStorageError(null);
retainedSend.current = null;
setUnconfirmedRequest(false);
setPublication(null);
setBody("");
setSelectedAttachmentIds([]);
setUploadedAttachments([]);
setUploadError(null);
idempotencyKey.current = null;
publish.reset();
}}
>
Dismiss delivery receipt
</Button>
)}
</div>
)}
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Ordinary board comments remain Paperclip-only.
</p>
<Button
size="sm"
disabled={
!body.trim() ||
publish.isPending ||
uploading ||
Boolean(publication) ||
Boolean(rejection) ||
Boolean(storageError) ||
!deliveryScopeReady
}
onClick={() => {
if (
uploadInFlight.current ||
!storageKey ||
loadedStorageKey.current !== storageKey ||
retainedScopeKey.current !== storageKey
)
return;
idempotencyKey.current ??= crypto.randomUUID();
const input = retainedSend.current ?? {
attachmentIds: selectedAttachmentIds,
attachmentNames: selectedAttachmentIds.map((id) => ({
id,
name:
taskAttachments.find((attachment) => attachment.id === id)
?.originalFilename ?? "Unnamed attachment",
})),
body: body.trim(),
idempotencyKey: idempotencyKey.current,
publication: null,
};
try {
if (!storageKey) throw new Error("Missing delivery scope");
writeBoardSendDraft(storageKey, input);
} catch {
setStorageError(
"Browser storage could not preserve this delivery identity. No update was sent. Restore browser storage, then reload to try again.",
);
return;
}
retainedSend.current = input;
setUnconfirmedRequest(true);
publish.mutate({
...input,
endpointId: binding.endpointId,
conversationId: binding.conversationId,
});
}}
>
{publish.isPending
? "Sending…"
: !rejection && (publish.isError || unconfirmedRequest)
? "Retry safely"
: "Send to channel"}
</Button>
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import {
boardSendDraftKey,
clearBoardSendDraft,
readBoardSendDraft,
writeBoardSendDraft,
canDismissBoardSendBatch,
} from "./board-send-draft";
describe("session-scoped Board send identity", () => {
it("round-trips the awaiting-consent anchor without storing transfer capabilities", () => {
const draft = {
body: "File for review",
attachmentIds: ["file-1"],
idempotencyKey: "stable-request-key-1",
publication: {
id: "file-part",
state: "awaiting_consent" as const,
attempts: 1,
},
};
writeBoardSendDraft("waiting", draft);
expect(readBoardSendDraft("waiting")).toEqual(draft);
});
it("requires consistent whole-batch terminal proof to release a retained anchor", () => {
const batch = {
publication: {
id: "file-part",
state: "cancelled" as const,
attempts: 1,
},
total: 3,
published: 1,
awaitingConsent: 0,
declined: 1,
expired: 1,
cancelled: 0,
settled: 3,
canDismiss: true,
};
expect(canDismissBoardSendBatch(batch)).toBe(true);
for (const change of [
{ canDismiss: undefined },
{ settled: 2 },
{ awaitingConsent: 1 },
{ cancelled: 1 },
{ declined: -1 },
{ expired: undefined },
{ parts: [{ id: "waiting", state: "pending" as const, attempts: 0 }] },
]) {
expect(canDismissBoardSendBatch({ ...batch, ...change })).toBe(false);
}
});
afterEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
it("keeps pending payloads isolated by company, task, endpoint and conversation", () => {
const scope = ["company", "task", "endpoint", "conversation"] as const;
const key = boardSendDraftKey(...scope);
const draft = {
body: "Intended external update",
attachmentIds: ["file-1"],
idempotencyKey: "stable-request-key-1",
publication: null,
};
writeBoardSendDraft(key, draft);
expect(readBoardSendDraft(key)).toEqual(draft);
for (let index = 0; index < scope.length; index += 1) {
const other = [...scope] as [string, string, string, string];
other[index] = "other";
expect(readBoardSendDraft(boardSendDraftKey(...other))).toBeNull();
}
clearBoardSendDraft(key);
expect(readBoardSendDraft(key)).toBeNull();
});
it("rejects corrupt retained identity instead of silently enabling a new send", () => {
sessionStorage.setItem("corrupt", '{"body":"draft"}');
expect(() => readBoardSendDraft("corrupt")).toThrow(
"Saved channel delivery identity",
);
});
it("preserves selected filename snapshots but rejects names outside the exact send", () => {
const draft = {
body: "Intended external update",
attachmentIds: ["file-1"],
attachmentNames: [{ id: "file-1", name: "selected-image.png" }],
idempotencyKey: "stable-request-key-1",
publication: null,
};
writeBoardSendDraft("named", draft);
expect(readBoardSendDraft("named")).toEqual(draft);
for (const attachmentNames of [
[],
[{ id: "other-file", name: "internal-only.txt" }],
[{ id: "file-1", name: 123 }],
[
{ id: "file-1", name: "selected-image.png" },
{ id: "file-1", name: "duplicate.png" },
],
]) {
sessionStorage.setItem(
"named",
JSON.stringify({ ...draft, attachmentNames }),
);
expect(() => readBoardSendDraft("named")).toThrow(
"Saved channel delivery identity",
);
}
});
it("surfaces storage write failures before a caller performs its send", () => {
vi.spyOn(
Object.getPrototypeOf(sessionStorage),
"setItem",
).mockImplementation(() => {
throw new Error("Storage full");
});
expect(() =>
writeBoardSendDraft("key", {
body: "draft",
attachmentIds: [],
idempotencyKey: "stable-request-key-1",
publication: null,
}),
).toThrow("Storage full");
});
it("retains only a negative receipt for selected files, never alongside a publication", () => {
const draft = {
body: "Keep this message",
attachmentIds: ["file-1"],
idempotencyKey: "stable-request-key-1",
publication: null,
rejection: {
code: "chat_board_send_attachments_already_bound" as const,
attachmentIds: ["file-1"],
},
};
writeBoardSendDraft("rejected", draft);
expect(readBoardSendDraft("rejected")).toEqual(draft);
for (const change of [
{ rejection: { code: "unknown", attachmentIds: ["file-1"] } },
{ rejection: { ...draft.rejection, attachmentIds: [] } },
{ rejection: { ...draft.rejection, attachmentIds: ["foreign"] } },
{
rejection: { ...draft.rejection, attachmentIds: ["file-1", "file-1"] },
},
{ publication: { id: "publication-1", state: "pending", attempts: 0 } },
]) {
sessionStorage.setItem(
"rejected",
JSON.stringify({ ...draft, ...change }),
);
expect(() => readBoardSendDraft("rejected")).toThrow(
"Saved channel delivery identity",
);
}
});
});
+173
View File
@@ -0,0 +1,173 @@
import {
CHAT_PUBLICATION_STATES,
type ChatPublicationSummary,
type ChatPublicationBatchStatus,
} from "@paperclipai/shared";
import { ApiError } from "@/api/client";
export type BoardSendRejection = {
code: "chat_board_send_attachments_already_bound";
attachmentIds: string[];
};
function isBoardSendRejection(
value: unknown,
selected: string[],
): value is BoardSendRejection {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const rejection = value as Partial<BoardSendRejection>;
return (
rejection.code === "chat_board_send_attachments_already_bound" &&
Array.isArray(rejection.attachmentIds) &&
rejection.attachmentIds.length > 0 &&
new Set(rejection.attachmentIds).size === rejection.attachmentIds.length &&
rejection.attachmentIds.every(
(id) => typeof id === "string" && selected.includes(id),
)
);
}
/** Only a durable negative receipt for this exact request permits correction.
* Generic 409s, transport errors and foreign receipts remain uncertain. */
export function readBoardSendRejection(
error: unknown,
request: {
endpointId: string;
conversationId: string;
idempotencyKey: string;
attachmentIds: string[];
},
): BoardSendRejection | null {
if (
!(error instanceof ApiError) ||
error.status !== 409 ||
!error.body ||
typeof error.body !== "object"
)
return null;
const details = (error.body as { details?: unknown }).details;
if (!isBoardSendRejection(details, request.attachmentIds)) return null;
const scope = details as BoardSendRejection & Record<string, unknown>;
if (
scope.endpointId !== request.endpointId ||
scope.conversationId !== request.conversationId ||
scope.idempotencyKey !== request.idempotencyKey
)
return null;
return { code: details.code, attachmentIds: [...details.attachmentIds] };
}
export interface RetainedBoardSend {
body: string;
attachmentIds: string[];
attachmentNames?: { id: string; name: string }[];
idempotencyKey: string;
publication: Pick<ChatPublicationSummary, "id" | "state" | "attempts"> | null;
rejection?: BoardSendRejection;
}
export function boardSendDraftKey(
companyId: string,
issueId: string,
endpointId: string,
conversationId: string,
) {
return `paperclip:board-send:v1:${JSON.stringify([companyId, issueId, endpointId, conversationId])}`;
}
export function readBoardSendDraft(key: string): RetainedBoardSend | null {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
const value = JSON.parse(raw) as Partial<RetainedBoardSend>;
if (
!value ||
typeof value !== "object" ||
typeof value.body !== "string" ||
!value.body.trim() ||
value.body.length > 100_000 ||
typeof value.idempotencyKey !== "string" ||
value.idempotencyKey.length < 16 ||
value.idempotencyKey.length > 200 ||
!Array.isArray(value.attachmentIds) ||
value.attachmentIds.length > 20 ||
!value.attachmentIds.every((id) => typeof id === "string") ||
(value.rejection !== undefined &&
(value.publication !== null ||
!isBoardSendRejection(value.rejection, value.attachmentIds))) ||
(value.attachmentNames !== undefined &&
(!Array.isArray(value.attachmentNames) ||
value.attachmentNames.length !== value.attachmentIds.length ||
new Set(value.attachmentNames.map((file) => file?.id)).size !==
value.attachmentNames.length ||
!value.attachmentNames.every(
(file) =>
file &&
typeof file.id === "string" &&
value.attachmentIds!.includes(file.id) &&
typeof file.name === "string",
))) ||
(value.publication !== null &&
(!value.publication ||
typeof value.publication.id !== "string" ||
!value.publication.id ||
!CHAT_PUBLICATION_STATES.includes(value.publication.state) ||
!Number.isSafeInteger(value.publication.attempts) ||
value.publication.attempts < 0))
)
throw new Error(
"Saved channel delivery identity could not be read. Check Activity before starting another send.",
);
return value as RetainedBoardSend;
}
export function writeBoardSendDraft(key: string, value: RetainedBoardSend) {
// This is a delivery identity, not a best-effort text draft. The caller must
// stop before POST when storage fails; otherwise reload could create a duplicate.
sessionStorage.setItem(key, JSON.stringify(value));
}
export function clearBoardSendDraft(key: string) {
sessionStorage.removeItem(key);
}
/** A terminal selected row alone cannot release a still-running batch. */
export function canDismissBoardSendBatch(
batch: ChatPublicationBatchStatus | undefined,
): boolean {
if (
!batch ||
batch.canDismiss !== true ||
!Number.isSafeInteger(batch.total) ||
batch.total <= 0
)
return false;
const counts = [
batch.published,
batch.declined,
batch.expired,
batch.cancelled,
];
if (
!counts.every(
(count) =>
typeof count === "number" && Number.isSafeInteger(count) && count >= 0,
)
)
return false;
return (
batch.settled === batch.total &&
batch.awaitingConsent === 0 &&
counts.reduce<number>((sum, count) => sum + count!, 0) === batch.total &&
(!batch.parts ||
(batch.parts.length === batch.total &&
batch.parts.every(
(part) =>
part.state === "published" ||
(part.state === "cancelled" &&
(!part.fileTransfer ||
["declined", "expired", "cancelled"].includes(
part.fileTransfer.phase,
))),
)))
);
}
@@ -135,6 +135,8 @@ export interface EnvironmentVariablesEditorProps {
footerHint?: ReactNode | null;
/** Reports editor-local draft changes that are not yet promoted to the parent value. */
onDirtyChange?: (dirty: boolean) => void;
/** Parent owns Save/Discard and flushes the editor through its ref. */
hideDraftActions?: boolean;
}
export interface EnvironmentVariablesEditorHandle {
@@ -156,6 +158,7 @@ export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorH
reservedPrefixes = DEFAULT_RESERVED_PREFIXES,
footerHint,
onDirtyChange,
hideDraftActions = false,
}: EnvironmentVariablesEditorProps, ref) {
const toast = useOptionalToastActions();
const editorRootRef = useRef<HTMLDivElement | null>(null);
@@ -518,7 +521,7 @@ export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorH
</div>
{hasUnsavedChanges && !disabled ? (
{hasUnsavedChanges && !disabled && !hideDraftActions ? (
<div
role="status"
className="mt-3 flex w-full flex-col gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-3 text-amber-950 shadow-sm dark:bg-amber-500/15 dark:text-amber-100 @[34rem]/env:flex-row @[34rem]/env:items-center @[34rem]/env:justify-between"
@@ -1,4 +1,5 @@
import { CachedTaskFilesButton } from "@/components/CachedTaskFilesButton";
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { createPortal } from "react-dom";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel";
@@ -741,12 +742,13 @@ export function IssueProperties({
);
const assigneeOverrideChrome = assigneeAdapterType === "claude_local"
&& assigneeOverrideAdapterConfig.chrome === true;
const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(assigneePrimaryAdapterConfig).provider ?? "codex") : undefined;
const { data: assigneeAdapterModels } = useQuery({
queryKey:
companyId && assigneeAdapterType
? queryKeys.agents.adapterModels(companyId, assigneeAdapterType)
? queryKeys.agents.adapterModels(companyId, assigneeAdapterType, null, catalogProvider)
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!),
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, { provider: catalogProvider }),
enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides,
});
const modelOverrideOptions = useMemo<InlineEntityOption[]>(() => {
@@ -2045,13 +2047,15 @@ export function IssueProperties({
const blockedByTrigger = blockedByRelations.length > 0 ? (
<div className="flex min-w-0 flex-col items-start gap-1">
{blockedByRelations.slice(0, 2).map((relation) => (
<PropertyChip key={relation.id}>
{relation.identifier ?? relation.title}
</PropertyChip>
<IssueReferencePill
key={relation.id}
issue={relation}
onRemove={(id) => onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== id) })}
/>
))}
{blockedByRelations.length > 2 ? (
<Badge variant="outline" className="border-border text-muted-foreground">
+{blockedByRelations.length - 2} more
<Badge asChild variant="outline" className="border-border text-muted-foreground hover:bg-accent/50">
<button type="button" onClick={() => setBlockedByOpen(true)}>+{blockedByRelations.length - 2} more</button>
</Badge>
) : null}
</div>
@@ -2061,13 +2065,11 @@ export function IssueProperties({
const subtasksTrigger = childIssues.length > 0 ? (
<div className="flex min-w-0 flex-col items-start gap-1">
{childIssues.slice(0, 2).map((child) => (
<PropertyChip key={child.id}>
{child.identifier ?? child.title}
</PropertyChip>
<IssueReferencePill variant="property" key={child.id} issue={child} className="min-w-0 max-w-full" />
))}
{childIssues.length > 2 ? (
<Badge variant="outline" className="border-border text-muted-foreground">
+{childIssues.length - 2} more
<Badge asChild variant="outline" className="border-border text-muted-foreground hover:bg-accent/50">
<button type="button" onClick={() => setSubtasksOpen(true)}>+{childIssues.length - 2} more</button>
</Badge>
) : null}
</div>
@@ -2105,25 +2107,19 @@ export function IssueProperties({
const parentIdentifier = issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier;
const parentTitle = issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId?.slice(0, 8);
const parentTrigger = issue.parentId ? (
<span
className="text-sm truncate min-w-0"
title={`${parentIdentifier ? `${parentIdentifier} ` : ""}${parentTitle ?? ""}`.trim()}
>
{parentIdentifier ? `${parentIdentifier} ` : ""}
{parentTitle}
</span>
<IssueReferencePill
variant="property"
issue={{
id: issue.parentId,
identifier: parentIdentifier ?? issue.parentId,
title: parentTitle ?? "Parent task",
status: issue.ancestors?.[0]?.status ?? currentParentIssue?.status,
}}
className="min-w-0 max-w-full"
/>
) : (
<span className="text-sm text-muted-foreground">None</span>
);
const parentLink = issue.parentId ? (
<Link
to={`/issues/${parentIdentifier ?? issue.parentId}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined;
const parentSearchActive = normalizedParentSearch.length > 0;
// When the user types, search on the server. The default list caps at 500 rows
// and sorts priority-first, so a medium-priority or low-priority match past that
@@ -2437,7 +2433,7 @@ export function IssueProperties({
triggerContent={parentTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
extra={parentLink}
separateTrigger={!!issue.parentId}
>
{parentContent}
</PropertyPicker>
@@ -2451,6 +2447,7 @@ export function IssueProperties({
setBlockedByOpen(open);
if (!open) setBlockedBySearch("");
}}
separateTrigger={blockedByRelations.length > 0}
triggerContent={blockedByTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
@@ -2516,7 +2513,7 @@ export function IssueProperties({
{blockingIssues.length > 0 ? (
<div className="flex flex-col items-start gap-1.5">
{visibleBlockingIssues.map((relation) => (
<IssueReferencePill key={relation.id} issue={relation} />
<IssueReferencePill variant="property" key={relation.id} issue={relation} />
))}
<ExpandRelationListButton
hiddenCount={hiddenBlockingIssueCount}
@@ -2535,6 +2532,7 @@ export function IssueProperties({
label="Subtasks"
open={subtasksOpen}
onOpenChange={setSubtasksOpen}
separateTrigger={childIssues.length > 0}
triggerContent={subtasksTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
@@ -398,7 +398,7 @@ describe("markdown work product review row", () => {
expect(container.querySelector('article[data-variant="compact"]')).not.toBeNull();
expect(container.querySelector(`img[src="${imagePath}"]`)).not.toBeNull();
expect(container.querySelector('a[aria-label="Open on GitHub: Artifact grouping PR"]')).not.toBeNull();
expect(container.querySelector('a[aria-label="Open gallery: Artifacts screenshot"]')).not.toBeNull();
expect(container.querySelector('button[aria-label="Open gallery: Artifacts screenshot"]')).not.toBeNull();
});
const typeSelect = container.querySelector('select[aria-label="Filter artifacts by type"]') as HTMLSelectElement;
@@ -17,6 +17,7 @@ export function PropertyPicker({
popoverAlign = "end",
extra,
stacked = false,
separateTrigger = false,
children,
}: {
inline?: boolean;
@@ -30,6 +31,8 @@ export function PropertyPicker({
extra?: ReactNode;
/** Top-aligns the row and vertically stacks chip collections in the trigger. */
stacked?: boolean;
/** Keep navigable relationship badges outside the picker button. */
separateTrigger?: boolean;
children: ReactNode;
}) {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
@@ -39,6 +42,43 @@ export function PropertyPicker({
triggerClassName,
);
if (separateTrigger) {
const trigger = (
<button
type="button"
className={cn(btnCn, "shrink-0")}
aria-label={`Edit ${label.toLowerCase()}`}
aria-expanded={open}
onClick={inline ? () => onOpenChange(!open) : undefined}
>
<ChevronDown className={cn("h-3 w-3 text-muted-foreground", open && "rotate-180")} aria-hidden />
</button>
);
return (
<div>
<PropertyRow label={label} wrap={stacked}>
<div className="flex min-w-0 max-w-full items-start gap-1.5">
{triggerContent}
{inline ? trigger : (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className={cn("p-1", popoverClassName)} align={popoverAlign} collisionPadding={16}>
{children}
</PopoverContent>
</Popover>
)}
</div>
{extra}
</PropertyRow>
{inline && open ? (
<div className={cn("rounded-md border border-border bg-popover p-1 mb-2", popoverClassName)}>
{children}
</div>
) : null}
</div>
);
}
if (inline) {
return (
<div>
@@ -0,0 +1,303 @@
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Check, ChevronRight } from "lucide-react";
import { adaptersApi } from "@/api/adapters";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { isNewAgentAdapterAllowed } from "@/lib/new-agent-adapters";
import { queryKeys } from "@/lib/queryKeys";
import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "../ui/dialog";
import { PillGuy } from "../onboarding/PillGuy";
export type AgentBasics = {
name: string;
adapterType: string;
runnerProvider: string;
};
const brandMarks: Record<string, { src: string; dark?: string }> = {
claude_local: { src: "/brands/claude-color.svg" },
codex_local: { src: "/brands/codex-color.svg" },
gemini_local: { src: "/brands/adapters/gemini-color.svg" },
kimi_local: {
src: "/brands/adapters/kimi-color-light.svg",
dark: "/brands/adapters/kimi-color.svg",
},
...Object.fromEntries(
[
["cursor", "cursor"],
["cursor_cloud", "cursor"],
["grok_local", "grok"],
["hermes_local", "hermesagent"],
["hermes_gateway", "hermesagent"],
["pi_local", "pi"],
].map(([type, icon]) => [
type,
{
src: `/brands/adapters/${icon}.svg`,
dark: `/brands/adapters/${icon}-dark.svg`,
},
]),
),
};
export function AdapterMark({
type,
className = "size-6",
}: {
type: string;
className?: string;
}) {
const Icon = getAdapterDisplay(type).icon;
const mark = brandMarks[type];
if (!mark) return <Icon className={className} />;
return (
<>
<img
src={mark.src}
className={cn(
"shrink-0 object-contain",
mark.dark && "dark:hidden",
className,
)}
alt=""
/>
{mark.dark && (
<img
src={mark.dark}
className={cn("hidden shrink-0 object-contain dark:block", className)}
alt=""
/>
)}
</>
);
}
export function AgentBasicsDialog({
open,
onClose,
onContinue,
initialAdapter = "",
onInvite,
}: {
open: boolean;
onClose: () => void;
onContinue: (basics: AgentBasics) => void;
initialAdapter?: string;
onInvite?: () => void;
}) {
const id = useId();
const cloud = Boolean(useCloudInstance());
const experimental = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: instanceSettingsApi.getExperimental,
enabled: open,
retry: false,
});
const [name, setName] = useState("");
const [adapterType, setAdapterType] = useState(initialAdapter);
const [runnerProvider, setRunnerProvider] = useState("codex");
const [step, setStep] = useState<"name" | "adapter">("name");
const {
data: adapters,
isPending,
error,
} = useQuery({
queryKey: queryKeys.adapters.all,
queryFn: adaptersApi.list,
enabled: open,
});
const choices = (adapters ?? []).filter(
(adapter) =>
adapter.loaded &&
!adapter.disabled &&
isNewAgentAdapterAllowed(adapter.type, {
cloud,
nativeRunnerEnabled: experimental.data?.enableNativeRunner === true,
}) &&
!["process", "http"].includes(adapter.type) &&
!getAdapterDisplay(adapter.type).comingSoon,
);
const validAdapter = choices.some((adapter) => adapter.type === adapterType);
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) onClose();
}}
>
<DialogContent
className={cn(
"flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 sm:max-w-(--sz-640px)",
step === "name" && "sm:max-w-(--sz-560px)",
)}
>
<div
className="flex items-center gap-2 px-6 py-5 text-xs text-muted-foreground"
aria-label="New agent progress"
>
<span
className={cn(step === "name" && "font-medium text-foreground")}
>
1. Name
</span>
<ChevronRight className="size-3" />
<span
className={cn(step === "adapter" && "font-medium text-foreground")}
>
2. Adapter
</span>
</div>
<form
className="flex min-h-0 flex-col"
onSubmit={(event) => {
event.preventDefault();
if (!name.trim()) return;
if (step === "name") setStep("adapter");
else if (validAdapter)
onContinue({ name: name.trim(), adapterType, runnerProvider });
}}
>
<div className="flex min-h-0 flex-col gap-7 overflow-y-auto px-6 pb-8 sm:px-10">
<div className="flex flex-col items-center gap-4 text-center">
<PillGuy state="dormant" className="size-16" />
<div className="space-y-2">
<DialogTitle className="text-3xl font-semibold tracking-tight">
{step === "name"
? "Meet your next agent"
: "Choose an adapter"}
</DialogTitle>
<DialogDescription className="text-base">
{step === "name"
? "Start with a name. Make them your own."
: `How should ${name.trim()} work?`}
</DialogDescription>
</div>
</div>
{step === "name" ? (
<div className="space-y-2">
<label htmlFor={id} className="text-sm font-medium">
Agent name
</label>
<Input
id={id}
autoFocus
maxLength={100}
placeholder="e.g. Darnold"
value={name}
onChange={(event) => setName(event.target.value)}
className="h-12 text-base"
/>
{onInvite && (
<Button
type="button"
variant="link"
className="px-0 text-muted-foreground"
onClick={onInvite}
>
Invite an external agent
</Button>
)}
</div>
) : (
<fieldset className="space-y-4">
<legend className="sr-only">Adapter</legend>
{isPending && (
<p role="status" className="text-sm text-muted-foreground">
Loading adapters…
</p>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error.message}
</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{choices.map((adapter) => {
const display = getAdapterDisplay(adapter.type);
return (
<label
key={adapter.type}
className="relative cursor-pointer"
>
<input
type="radio"
name="new-agent-adapter"
value={adapter.type}
checked={adapterType === adapter.type}
onChange={() => setAdapterType(adapter.type)}
className="peer sr-only"
/>
<span
className={cn(
"flex h-full flex-col items-center gap-2 rounded-lg border px-3 py-4 text-center peer-focus-visible:ring-2 peer-focus-visible:ring-ring hover:bg-accent/40",
adapterType === adapter.type
? "border-foreground/40 bg-accent"
: "border-border bg-card",
)}
>
<AdapterMark type={adapter.type} />
<span className="text-sm font-medium">
{display.label}
</span>
{adapterType === adapter.type && (
<Check className="absolute right-2 top-2 size-3.5" />
)}
</span>
</label>
);
})}
</div>
{validAdapter && adapterType === "paperclip_runner" && (
<label className="flex flex-col gap-2 text-sm font-medium">
Runner
<select
className="rounded-md border border-border bg-background px-3 py-2"
value={runnerProvider}
onChange={(event) =>
setRunnerProvider(event.target.value)
}
>
<option value="codex">Codex (app server)</option>
<option value="claude">Claude (ACPX)</option>
<option value="opencode">OpenCode</option>
</select>
</label>
)}
</fieldset>
)}
</div>
<div className="flex justify-between gap-4 border-t border-border px-6 py-4">
<Button
type="button"
variant="ghost"
onClick={() => (step === "name" ? onClose() : setStep("name"))}
>
{step === "name" ? (
"Cancel"
) : (
<>
<ArrowLeft className="size-4" />
Back
</>
)}
</Button>
<Button
type="submit"
disabled={!name.trim() || (step === "adapter" && !validAdapter)}
>
{step === "name" ? "Choose adapter" : "Configure agent"}
<ArrowRight className="size-4" />
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,244 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentProviderConnection } from "./AgentProviderConnection";
import { ApiError } from "@/api/client";
const mocks = vi.hoisted(() => ({
auth: vi.fn(),
login: vi.fn(),
personal: vi.fn(),
organization: vi.fn(),
}));
vi.mock("@/api/agents", () => ({
agentsApi: {
getAdapterAuthSignal: mocks.auth,
getClaudeOAuthTokenStatus: mocks.login,
},
}));
vi.mock("@/api/secrets", () => ({
secretsApi: { listMyUserSecrets: mocks.personal, list: mocks.organization },
}));
vi.mock("../AgentConfigForm", () => ({
AdapterLoginPanel: () => <div>New subscription login</div>,
}));
let root: Root;
let host: HTMLDivElement;
let client: QueryClient;
afterEach(() => {
flushSync(() => root?.unmount());
host?.remove();
client?.clear();
vi.resetAllMocks();
});
async function mount(
adapterType: "claude_local" | "codex_local" = "claude_local",
savedLogin = false,
canLogin = true,
codexSubscriptions = false,
savedApiKeys = true,
cachedClaudeLogin = false,
) {
const key =
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
mocks.auth.mockResolvedValue({
status: codexSubscriptions ? "unknown" : "present",
});
mocks.login.mockImplementation(async () => {
if (savedLogin) return { secretId: "oauth", latestVersion: 1 };
throw new ApiError("Not found", 404, null);
});
mocks.personal.mockResolvedValue([
{
definition: {
id: "d1",
companyId: "c1",
key: `${key}.setup.1`,
name: "Personal key",
status: "active",
},
secret: { companyId: "c1", status: "active" },
},
]);
mocks.organization.mockResolvedValue([
{
id: "s1",
companyId: "c1",
key,
name: "Company key",
scope: "company",
status: "active",
},
...(codexSubscriptions
? [
{
id: "codex-home",
companyId: "c1",
name: "CODEX_HOME_team",
scope: "company",
status: "active",
},
]
: []),
]);
if (!savedApiKeys) {
mocks.personal.mockResolvedValue([]);
mocks.organization.mockResolvedValue([]);
}
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (cachedClaudeLogin) {
client.setQueryData(["claude-oauth-token-status", "c1"], { secretId: "cached-claude", latestVersion: 1 });
mocks.auth.mockResolvedValue({ status: "absent" });
}
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
const test = vi.fn().mockResolvedValue(true);
const connected = vi.fn();
flushSync(() =>
root.render(
<QueryClientProvider client={client}>
<AgentProviderConnection
companyId="c1"
adapterType={adapterType}
environmentId="e1"
canLogin={canLogin}
onBack={() => {}}
testConnection={test}
onConnected={connected}
/>
</QueryClientProvider>,
),
);
await vi.waitFor(() => expect(mocks.personal).toHaveBeenCalled());
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
if (savedApiKeys) await vi.waitFor(() => expect(host.textContent).toContain("2 saved API keys"));
return { test, connected, key };
}
function click(text: string) {
const button = [...host.querySelectorAll("button")].find((b) =>
b.textContent?.includes(text),
)!;
expect(button).toBeTruthy();
flushSync(() => button.click());
}
function openProvider() {
flushSync(() =>
(host.querySelector('[role="radio"]') as HTMLElement).click(),
);
}
describe("AgentProviderConnection reuse", () => {
it("defaults to subscription when no saved credentials exist", async () => {
await mount("claude_local", false, true, false, false);
expect(host.textContent).toContain("Use API key instead");
click("Use API key instead");
openProvider();
expect(host.querySelector('input[type="password"]')).not.toBeNull();
});
it("does not use a cached Claude login for Codex", async () => {
await mount("codex_local", false, true, false, false, true);
openProvider();
expect(host.textContent).toContain("New subscription login");
expect(host.textContent).not.toContain("saved Claude subscription");
expect(host.textContent).not.toContain("Use saved subscription");
expect(mocks.login).not.toHaveBeenCalled();
});
it("reuses a saved ChatGPT account when the sandbox auth signal is unknown", async () => {
const { test, connected } = await mount("codex_local", false, true, true);
openProvider();
expect(host.textContent).not.toContain("New subscription login");
click("Use saved subscription");
await vi.waitFor(() =>
expect(connected).toHaveBeenCalledWith({
env: {
CODEX_HOME: {
type: "secret_ref",
secretId: "codex-home",
version: "latest",
},
},
}),
);
expect(test).toHaveBeenCalledWith(connected.mock.calls[0][0]);
flushSync(() => {
const select = host.querySelector("select")!;
select.value = "";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(host.textContent).toContain("New subscription login");
});
it.each(["claude_local", "codex_local"] as const)(
"passes a personal reference without credentials for %s",
async (adapter) => {
const { test, connected, key } = await mount(adapter);
openProvider();
const select = host.querySelector("select")!;
expect(select.value).toBe("user:d1");
click("Use saved API key");
await vi.waitFor(() =>
expect(connected).toHaveBeenCalledWith({
env: {
[key]: {
type: "user_secret_ref",
key: `${key}.setup.1`,
version: "latest",
},
},
}),
);
expect(test).toHaveBeenCalledWith(connected.mock.calls[0][0]);
},
);
it("uses an organization reference and requires a new key after switching away", async () => {
const { connected, key } = await mount();
openProvider();
const select = host.querySelector("select")!;
flushSync(() => {
select.value = "company:s1";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
click("Use saved API key");
await vi.waitFor(() =>
expect(connected).toHaveBeenCalledWith({
env: {
[key]: { type: "secret_ref", secretId: "s1", version: "latest" },
},
}),
);
flushSync(() => {
select.value = "";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(host.querySelector('input[type="password"]')).not.toBeNull();
const button = [...host.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Connect"),
)!;
expect(button.disabled).toBe(true);
await client.invalidateQueries();
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
expect(host.querySelector("select")!.value).toBe("");
expect(host.querySelector('input[type="password"]')).not.toBeNull();
});
it("reuses saved Claude login even without a login-capable environment", async () => {
const { test } = await mount("claude_local", true, false);
openProvider();
await vi.waitFor(() =>
expect(host.textContent).toContain("Use saved subscription"),
);
click("Use saved subscription");
await vi.waitFor(() =>
expect(test).toHaveBeenCalledWith(
expect.objectContaining({ applyStoredClaudeLogin: true }),
),
);
});
it("does not treat an environment credential as a stored Claude login", async () => {
const { test } = await mount();
click("Use subscription instead");
openProvider();
click("Connect");
await vi.waitFor(() => expect(test).toHaveBeenCalledWith({ env: {} }));
});
});
@@ -0,0 +1,330 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion } from "motion/react";
import {
SavedProviderKeySelect,
useSavedProviderKeys,
} from "../onboarding/SavedProviderKeySelect";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { AdapterLoginPanel } from "../AgentConfigForm";
import {
OnboardingCardField,
OnboardingLoginCard,
} from "../AdapterLoginChrome";
import { ModelSourceTiles } from "../onboarding/ModelSourceTiles";
import { CredentialModeLink } from "../onboarding/CredentialModeLink";
import { FooterNav } from "../onboarding/FooterNav";
import { MAKE_ROOM, CARD_ENTER } from "../onboarding/onboarding-motion";
import { buildFixedClaudeOAuthBinding } from "../environment-variables-editor/model";
import type { EnvBinding } from "@paperclipai/shared";
export type ProviderConnection = {
env: Record<string, EnvBinding>;
/** Kept in memory until the user finishes setup. */
credentials?: Record<string, string>;
storedSessionId?: string;
applyStoredClaudeLogin?: boolean;
};
export function AgentProviderConnection({
companyId,
adapterType,
environmentId,
canLogin,
onConnected,
onBack,
testConnection,
testError,
}: {
companyId: string;
adapterType: "claude_local" | "codex_local";
environmentId: string | null;
canLogin: boolean;
onConnected: (connection: ProviderConnection) => void;
onBack: () => void;
testConnection: (connection: ProviderConnection) => Promise<boolean>;
testError?: string | null;
}) {
const epoch = useRef(0);
useEffect(
() => () => {
epoch.current++;
},
[],
);
const cancel = () => {
epoch.current++;
setBusy(false);
setOpened(false);
};
const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(null);
const [opened, setOpened] = useState(false);
const [apiKey, setApiKey] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [storedConnection, setStoredConnection] =
useState<ProviderConnection | null>(null);
const provider = adapterType === "claude_local" ? "Claude" : "OpenAI";
const envKey =
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
const savedKeys = useSavedProviderKeys(companyId, envKey);
const [subscriptionId, setSubscriptionId] = useState<string | null>(null);
const savedSubscription =
adapterType === "codex_local"
? savedKeys.subscriptions.find(
(option) =>
option.id === (subscriptionId ?? savedKeys.subscriptions[0]?.id),
)
: undefined;
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const selectedKey = savedKeys.options.find(
(option) => option.id === (selectedKeyId ?? savedKeys.options[0]?.id),
);
const storedLogin = savedKeys.storedLogin;
const method = methodChoice ?? (
(adapterType === "claude_local" ? storedLogin.data : savedKeys.subscriptions.length)
? "subscription" : savedKeys.options.length ? "api" : "subscription"
);
const auth = useQuery({
queryKey: queryKeys.agents.authSignal(
companyId,
adapterType,
environmentId,
),
queryFn: () =>
agentsApi.getAdapterAuthSignal(
companyId,
adapterType,
environmentId ?? undefined,
),
retry: false,
});
async function connect() {
if (busy) return;
const run = ++epoch.current;
setBusy(true);
setError(null);
try {
const connection =
method === "api"
? selectedKey
? { env: { [envKey]: selectedKey.binding } }
: (storedConnection ?? {
env: {},
credentials: { [envKey]: apiKey.trim() },
})
: {
env: savedSubscription
? { CODEX_HOME: savedSubscription.binding }
: {},
...(adapterType === "claude_local" && storedLogin.data
? {
env: buildFixedClaudeOAuthBinding(),
applyStoredClaudeLogin: true,
}
: {}),
};
if (run !== epoch.current) return;
if (method === "api") {
setApiKey("");
if (!selectedKey) setStoredConnection(connection);
}
const connected = await testConnection(connection);
if (run !== epoch.current) return;
if (connected) onConnected(connection);
else
setError(
"The provider did not respond. Check the connection and try again.",
);
} catch (cause) {
if (run !== epoch.current) return;
setError(
cause instanceof Error
? cause.message
: "Could not connect to the provider.",
);
} finally {
if (run === epoch.current) setBusy(false);
}
}
const needsLogin =
method === "subscription" &&
canLogin &&
environmentId &&
!savedSubscription &&
!savedKeys.loading &&
!storedLogin.data &&
(auth.data?.status !== "present" || subscriptionId === "");
return (
<div>
<ModelSourceTiles
label="Connect your model provider"
sources={[
{
id: adapterType,
label: provider,
icon: (
<img
src={`/brands/${adapterType === "claude_local" ? "claude" : "codex"}-color.svg`}
className="size-6"
alt=""
/>
),
},
]}
mode={method}
selectedId={opened ? adapterType : null}
collapsed={opened}
onSelect={() => setOpened(true)}
/>
{!opened && (
<div className="-ml-3 mt-1">
<CredentialModeLink
mode={method}
onChange={(next) => {
setMethod(next);
setError(null);
}}
/>
</div>
)}
{!opened && savedKeys.options.length > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{savedKeys.options.length} saved API{" "}
{savedKeys.options.length === 1 ? "key available" : "keys available"}.
</p>
)}
{method === "subscription" &&
adapterType === "codex_local" &&
savedKeys.subscriptions.length > 0 && (
<SavedProviderKeySelect
options={savedKeys.subscriptions}
value={savedSubscription?.id ?? ""}
onChange={setSubscriptionId}
loading={false}
error={false}
kind="subscription"
disabled={busy}
/>
)}
<motion.div
initial={false}
animate={{ height: opened ? "auto" : 0, opacity: opened ? 1 : 0 }}
transition={{ height: MAKE_ROOM, opacity: CARD_ENTER }}
className="overflow-hidden"
>
{opened && (
<div className="pt-5">
{method === "api" ? (
<OnboardingLoginCard
instruction={
savedKeys.options.length
? "Choose a saved API key or enter a new one"
: `Provide your ${provider} API key to connect`
}
>
<SavedProviderKeySelect
{...savedKeys}
value={selectedKey?.id ?? ""}
disabled={busy}
onChange={(id) => {
setSelectedKeyId(id);
setApiKey("");
setStoredConnection(null);
setError(null);
}}
/>
{!selectedKey && (
<OnboardingCardField
label="API key"
masked
autoFocus
value={apiKey}
placeholder={
storedConnection
? "Key entered. Retry the connection."
: "Enter API key here"
}
onChange={(value) => {
setSelectedKeyId("");
setApiKey(value);
setStoredConnection(null);
}}
onSubmit={() => void connect()}
disabled={busy}
/>
)}
</OnboardingLoginCard>
) : needsLogin ? (
<AdapterLoginPanel
companyId={companyId}
adapterType={adapterType}
environmentId={environmentId}
chrome="onboarding"
autoStart
onStored={(storedSessionId) => {
const connection = {
env: buildFixedClaudeOAuthBinding(),
storedSessionId,
};
setStoredConnection(connection);
onConnected(connection);
}}
onConnected={() => {
if (adapterType === "codex_local") onConnected({ env: {} });
}}
/>
) : savedSubscription ? null : (
<p className="text-sm text-muted-foreground">
{storedLogin.data
? "Use your saved Claude subscription for this agent."
: canLogin
? "Use the existing provider connection for this environment."
: `Use the ${provider} login on this machine. If you haven’t signed in yet, run ${adapterType === "claude_local" ? "claude auth login" : "codex login"} in your terminal, then connect.`}
</p>
)}
</div>
)}
</motion.div>
{method === "subscription" && storedLogin.isError && (
<p role="alert" className="mt-4 text-sm text-destructive">
Could not check your saved Claude subscription. Try again.
</p>
)}
{error && (
<p role="alert" className="mt-4 text-sm text-destructive">
{testError ?? error}
</p>
)}
<FooterNav
onBack={() => {
if (opened) cancel();
else onBack();
}}
primaryLabel={
busy
? "Connecting"
: method === "subscription" &&
(storedLogin.data || savedSubscription)
? "Use saved subscription"
: method === "api" && selectedKey
? "Use saved API key"
: "Connect"
}
primaryDisabled={
auth.isPending ||
savedKeys.loading ||
(adapterType === "claude_local" && storedLogin.isPending) ||
!opened ||
Boolean(needsLogin) ||
(method === "api" &&
!apiKey.trim() &&
!storedConnection &&
!selectedKey)
}
loading={busy}
onPrimary={() => void connect()}
/>
</div>
);
}
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "@/api/access";
import { queryKeys } from "@/lib/queryKeys";
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
/** Preserve the existing agent-only invitation path beside the setup wizard. */
export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
companyId: string;
onClose: () => void;
onBack: () => void;
}) {
const cache = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
const [message, setMessage] = useState("");
const [prompt, setPrompt] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(false);
async function copy(value: string) {
try {
await copyTextToClipboard(value);
if (mounted.current) { setCopied(true); setCopyError(false); }
} catch {
if (mounted.current) setCopyError(true);
}
}
const createInvite = useMutation({
mutationFn: async () => {
const invite = await accessApi.createCompanyInvite(companyId, {
allowedJoinTypes: "agent",
humanRole: null,
agentMessage: message.trim() || null,
});
void cache.invalidateQueries({ queryKey: queryKeys.access.invites(companyId, "all", 5) });
const path = invite.onboardingTextUrl ?? invite.onboardingTextPath ?? `/api/invites/${invite.token}/onboarding.txt`;
const onboardingTextUrl = new URL(path, window.location.origin).href;
const manifest = await accessApi.getInviteOnboarding(invite.token).catch(() => null);
return buildAgentOnboardingPrompt({
onboardingTextUrl,
connectionCandidates: manifest?.onboarding.connectivity?.connectionCandidates ?? null,
testResolutionUrl: manifest?.onboarding.connectivity?.testResolutionEndpoint?.url ?? null,
});
},
onSuccess: async (value) => {
if (!mounted.current) return;
setPrompt(value);
await copy(value);
},
});
return <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-h-(--sz-calc-16) overflow-y-auto sm:max-w-2xl">
<DialogTitle>{prompt ? "Agent onboarding prompt" : "Invite an external agent"}</DialogTitle>
<DialogDescription>
{prompt ? "Send this one-time prompt to the agent that should join your organization."
: "Generate a one-time onboarding prompt for an external agent. An organization admin must approve its join request before it can claim an API key."}
</DialogDescription>
{prompt ? <>
<Textarea aria-label="Agent onboarding prompt" readOnly value={prompt} className="min-h-64 font-mono text-xs" />
{copyError && <p role="alert" className="text-sm text-muted-foreground">Clipboard unavailable. Copy the prompt manually from the field above.</p>}
<div className="flex justify-between gap-4">
<Button variant="ghost" onClick={onClose}>Done</Button>
<Button variant="outline" onClick={() => void copy(prompt)}>{copied ? "Copied prompt" : "Copy prompt"}</Button>
</div>
</> : <>
<label className="space-y-2 text-sm">
<span>Optional message for the agent</span>
<Textarea value={message} onChange={(event) => setMessage(event.target.value)} maxLength={4000} className="min-h-24" />
</label>
{createInvite.error && <p role="alert" className="text-sm text-destructive">{createInvite.error.message}</p>}
<div className="flex justify-between gap-4">
<Button variant="ghost" onClick={onBack}>Back</Button>
<Button disabled={createInvite.isPending} onClick={() => createInvite.mutate()}>
{createInvite.isPending ? "Generating…" : "Generate onboarding prompt"}
</Button>
</div>
</>}
</DialogContent>
</Dialog>;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,122 @@
import { useQuery } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client";
import { secretsApi } from "@/api/secrets";
import { queryKeys } from "@/lib/queryKeys";
import {
savedProviderKeys,
savedCodexSubscriptions,
type SavedProviderKey,
} from "@/lib/saved-provider-credentials";
export function useSavedProviderKeys(
companyId: string | null,
envKey: string,
enabled = true,
) {
const personal = useQuery({
queryKey: queryKeys.secrets.myUserSecrets(companyId ?? ""),
queryFn: () => secretsApi.listMyUserSecrets(companyId!),
enabled: Boolean(companyId) && enabled,
retry: false,
});
const organization = useQuery({
queryKey: queryKeys.secrets.list(companyId ?? ""),
queryFn: () => secretsApi.list(companyId!),
enabled: Boolean(companyId) && enabled,
retry: false,
});
const storedLogin = useQuery({
// Disabled queries still return cached data. Keep other providers away
// from the shared Claude login cache.
queryKey: ["claude-oauth-token-status", envKey === "ANTHROPIC_API_KEY" ? companyId : null],
queryFn: async () => {
try {
return await agentsApi.getClaudeOAuthTokenStatus(companyId!);
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
},
enabled: Boolean(companyId) && enabled && envKey === "ANTHROPIC_API_KEY",
retry: false,
});
return {
storedLogin,
options: savedProviderKeys(
companyId ?? "",
envKey,
personal.data ?? [],
organization.data ?? [],
),
subscriptions: savedCodexSubscriptions(
companyId ?? "",
organization.data ?? [],
),
// Background refreshes must not unmount an active login panel sharing this query.
loading: personal.isLoading || organization.isLoading || storedLogin.isLoading,
error: personal.isError || organization.isError,
};
}
export function SavedProviderKeySelect({
options,
value,
onChange,
loading,
error,
disabled,
kind = "api",
}: {
options: SavedProviderKey[];
value: string;
onChange: (id: string) => void;
loading: boolean;
error: boolean;
disabled?: boolean;
kind?: "api" | "subscription";
}) {
return (
<div className="space-y-2">
{options.length > 0 && (
<label className="block space-y-2 text-sm">
<span>{kind === "api" ? "API key" : "Subscription"}</span>
<select
aria-label={kind === "api" ? "Saved API key" : "Saved subscription"}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
<option value="">
{kind === "api"
? "Enter a new API key"
: "Sign in to another account"}
</option>
</select>
</label>
)}
{loading && (
<p role="status" className="text-sm text-muted-foreground">
Checking saved API keys…
</p>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
Some saved keys could not be loaded. You can still enter a new key.
</p>
)}
{value && (
<p className="text-sm text-muted-foreground">
Reuse this saved {kind === "api" ? "key" : "subscription"} for this
agent.
</p>
)}
</div>
);
}
+142 -52
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Check,
ChevronLeft,
@@ -24,6 +24,7 @@ import {
useTaskChatComposerTakeoverActions,
} from "./TaskChatComposerTakeoverContext";
import { TaskChatRichInput } from "./TaskChatRichInput";
import { parseCssTimeMs } from "./motion-tokens";
import { matchSafeQuestionValidationPattern } from "./question-validation-pattern";
type Question = PaperclipQuestionSet["questions"][number];
@@ -39,6 +40,11 @@ export interface QuestionFormProps {
imageUploadHandler?: (file: File) => Promise<string>;
mentions?: MentionOption[];
onSubmit: (response: PaperclipQuestionResponse) => void | Promise<void>;
/**
* Resolves the request itself (a timeline card cancelling the interaction).
* Inside the composer takeover the form falls back to dismissing the
* takeover, which returns the plain composer without touching the request.
*/
onCancel?: () => void | Promise<void>;
}
@@ -103,6 +109,7 @@ function SelectOption({
recommended,
selected,
multiple,
confirming = false,
disabled,
onClick,
}: {
@@ -112,6 +119,7 @@ function SelectOption({
recommended?: boolean;
selected: boolean;
multiple: boolean;
confirming?: boolean;
disabled: boolean;
onClick: () => void;
}) {
@@ -123,9 +131,9 @@ function SelectOption({
aria-checked={selected}
disabled={disabled}
className={cn(
"flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
"tc-question-option flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
selected
? "bg-muted/80"
? "bg-foreground/5"
: recommended
? "bg-muted/50"
: "hover:bg-muted/40",
@@ -139,6 +147,7 @@ function SelectOption({
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center border",
multiple ? "rounded-sm" : "rounded-full",
confirming && "tc-question-choice-confirm",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50",
@@ -259,6 +268,21 @@ export function QuestionForm({
const [working, setWorking] = useState<"submit" | "cancel" | null>(null);
const [inputUploading, setInputUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingAdvance, setPendingAdvance] = useState<{
page: number;
questionId: string;
optionId: string;
} | null>(null);
const promptRef = useRef<HTMLParagraphElement>(null);
const previousPage = useRef(page);
useEffect(() => {
if (previousPage.current !== page) {
promptRef.current?.focus();
previousPage.current = page;
}
}, [page]);
const [filters, setFilters] = useState<Record<string, string>>({});
useEffect(() => {
@@ -272,6 +296,38 @@ export function QuestionForm({
}, [answers, customActive, draftKey, page]);
const question = questionSet.questions[page];
useEffect(() => {
if (!pendingAdvance) return;
if (
disabled ||
working ||
inputUploading ||
pendingAdvance.page !== page ||
pendingAdvance.questionId !== question?.id ||
page >= questionSet.questions.length - 1
) {
setPendingAdvance(null);
return;
}
const duration = getComputedStyle(document.documentElement)
.getPropertyValue("--motion-question-confirm")
.trim();
const durationMs = parseCssTimeMs(duration) || 0;
const advance = () => {
setPendingAdvance(null);
setPage(page + 1);
};
// With reduced motion (or without CSS), no visual hold is needed.
if (durationMs <= 0) {
advance();
return;
}
const timer = window.setTimeout(advance, durationMs);
return () => window.clearTimeout(timer);
}, [
pendingAdvance, page, question?.id, questionSet.questions.length,
disabled, working, inputUploading,
]);
const validationErrors = useMemo(
() =>
Object.fromEntries(
@@ -311,6 +367,7 @@ export function QuestionForm({
}
function toggleOption(optionId: string) {
if (disabled || working || inputUploading) return;
const optionIds = multiple
? selected.includes(optionId)
? selected.filter((candidate) => candidate !== optionId)
@@ -321,16 +378,20 @@ export function QuestionForm({
selectedOptionIds: optionIds,
...(!multiple ? { customText: undefined } : {}),
};
const nextAnswers = { ...answers, [question.id]: nextAnswer };
setAnswers(nextAnswers);
setAnswers({ ...answers, [question.id]: nextAnswer });
if (!multiple) {
setCustomActive((current) => ({ ...current, [question.id]: false }));
if (page < questionSet.questions.length - 1) setPage(page + 1);
else void submit(nextAnswers);
// Briefly confirm the selected choice before moving on. The last page needs an
// explicit submit, and custom answers stay open for typing.
if (page < questionSet.questions.length - 1) {
setError(null);
setPendingAdvance({ page, questionId: question.id, optionId });
}
}
}
function toggleCustom() {
setPendingAdvance(null);
const active = !isCustomActive;
setCustomActive((current) => ({ ...current, [question.id]: active }));
updateAnswer({
@@ -343,11 +404,21 @@ export function QuestionForm({
}
async function submit(responseAnswers: Record<string, Answer> = answers) {
const responseIsValid = questionSet.questions.every(
if (disabled || working || inputUploading) return;
const invalidIndex = questionSet.questions.findIndex(
(candidate) =>
answerError(candidate, responseAnswers[candidate.id]) == null,
answerError(candidate, responseAnswers[candidate.id]) != null,
);
if (!responseIsValid || disabled || working || inputUploading) return;
if (invalidIndex >= 0) {
// A required answer is missing: the pagination arrows browse without
// validating, and a restored draft can land past it. Go back to that
// question and say so rather than dropping the send.
setPage(invalidIndex);
setError(
`Question ${invalidIndex + 1} needs an answer before you can send.`,
);
return;
}
setWorking("submit");
setError(null);
try {
@@ -387,12 +458,22 @@ export function QuestionForm({
const currentError = validationErrors[question.id];
const isLastPage = page === questionSet.questions.length - 1;
const showQuestionActionButton =
multiple ||
(isLastPage && (question.answerMode !== "single_select" || isCustomActive));
const showActionRow = Boolean(
takeoverActions?.skipButton || onCancel || showQuestionActionButton,
);
const busy = disabled || working != null || inputUploading;
// Cancel resolves the request when the host owns that; otherwise it just
// closes the composer takeover so the user can type freely.
const cancelAction = onCancel
? () => void cancel()
: takeoverActions?.dismiss;
/** Leaves the current question unanswered and moves on (or sends). */
function skipQuestion() {
if (busy) return;
const { [question.id]: _skipped, ...rest } = answers;
setAnswers(rest);
setCustomActive((current) => ({ ...current, [question.id]: false }));
if (isLastPage) void submit(rest);
else setPage(page + 1);
}
const pagination =
questionSet.questions.length > 1 ? (
<nav
@@ -417,6 +498,8 @@ export function QuestionForm({
size="icon-xs"
variant="ghost"
aria-label="Next question"
// The arrows browse; they do not validate. A send that finds an
// earlier answer missing returns to that question (see submit).
disabled={disabled || working != null || isLastPage}
onClick={() => setPage((current) => current + 1)}
>
@@ -436,10 +519,13 @@ export function QuestionForm({
}
return (
<div
key={question.id}
className="tc-question-page"
onKeyDown={(event) => {
if (
disabled ||
working ||
event.repeat ||
question.answerMode === "text" ||
event.metaKey ||
event.ctrlKey ||
@@ -483,6 +569,8 @@ export function QuestionForm({
</p>
) : null}
<p
ref={promptRef}
tabIndex={-1}
id={`${id}-${question.id}-prompt`}
className="text-sm font-medium leading-5 text-foreground"
>
@@ -547,6 +635,7 @@ export function QuestionForm({
description={option.description}
recommended={option.recommended}
selected={selected.includes(option.id)}
confirming={pendingAdvance?.questionId === question.id && pendingAdvance.optionId === option.id}
multiple={multiple}
disabled={disabled || working != null}
onClick={() => toggleOption(option.id)}
@@ -599,43 +688,44 @@ export function QuestionForm({
</div>
) : null}
</div>
{showActionRow ? (
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
{takeoverActions?.skipButton}
{onCancel ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={disabled || working != null || inputUploading}
onClick={() => void cancel()}
>
{working === "cancel" ? (
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
) : null}{" "}
Cancel
</Button>
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
{cancelAction ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={cancelAction}
>
{working === "cancel" ? (
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
) : null}{" "}
Cancel
</Button>
) : null}
{!question.required ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
onClick={skipQuestion}
>
Skip
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={busy || (isLastPage ? !allValid : currentError != null)}
onClick={progressOrSubmit}
>
{working === "submit" ? (
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
) : null}
{showQuestionActionButton ? (
<Button
type="button"
size="sm"
disabled={
disabled ||
working != null ||
inputUploading ||
(isLastPage ? !allValid : currentError != null)
}
onClick={progressOrSubmit}
>
{working === "submit" ? (
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
) : null}
{questionSet.submitLabel ?? "Submit answers"}
</Button>
) : null}
</div>
) : null}
{isLastPage ? (questionSet.submitLabel ?? "Submit answers") : "Next"}
</Button>
</div>
</div>
);
}
@@ -1,7 +1,11 @@
import type { CSSProperties } from "react";
import { useContext, useState, type CSSProperties } from "react";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { ImageGalleryModal } from "@/components/ImageGalleryModal";
import { isImageContentType, isVideoLikeOutput } from "@/lib/issue-output";
import type { IssueWorkProduct } from "@paperclipai/shared";
import {
ExternalLink,
Maximize2,
File,
FileText,
Film,
@@ -123,10 +127,12 @@ export interface RichWorkProductCardProps {
}
export function RichWorkProductCard({ workProduct, href, variant = "card" }: RichWorkProductCardProps) {
const openIssueGallery = useContext(IssueGalleryContext);
const [galleryOpen, setGalleryOpen] = useState(false);
const metadata = workProduct.metadata;
const contentType = stringMeta(metadata, "contentType") ?? "";
const isImage = contentType.startsWith("image/");
const isVideo = contentType.startsWith("video/");
const isImage = isImageContentType(contentType);
const isVideo = isVideoLikeOutput(contentType, stringMeta(metadata, "originalFilename"));
let Icon: LucideIcon = File;
let meta: Array<string | null> = [];
let action = "Open preview";
@@ -211,6 +217,13 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
? stringMeta(metadata, "openPath", "contentPath") ?? href
: null;
const mediaPath = workProduct.type === "artifact" && (isImage || isVideo)
? stringMeta(metadata, "contentPath", "openPath") ?? href
: null;
const openGallery = () => {
if (mediaPath && !openIssueGallery?.(mediaPath)) setGalleryOpen(true);
};
return (
<article
className={cn(
@@ -237,12 +250,30 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
</div>
<div className={cn("flex shrink-0 items-center", compact ? "gap-1.5" : "gap-2")}>
{chip ? <Chip chip={chip} /> : null}
{href ? (
{mediaPath ? (
<button type="button" onClick={openGallery} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline">
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<Maximize2 aria-hidden className="h-3 w-3" />
</button>
) : href ? (
<a href={href} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline" target={href.startsWith("http") ? "_blank" : undefined} rel={href.startsWith("http") ? "noreferrer" : undefined}>
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<ExternalLink aria-hidden className="h-3 w-3" />
</a>
) : null}
</div>
{galleryOpen && mediaPath ? (
<ImageGalleryModal
items={[{
id: workProduct.id,
contentPath: mediaPath,
downloadPath: stringMeta(metadata, "downloadPath") ?? undefined,
contentType,
originalFilename: stringMeta(metadata, "originalFilename") ?? workProduct.title,
}]}
initialIndex={0}
open
onOpenChange={setGalleryOpen}
/>
) : null}
</article>
);
}
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RunnerGoalProjection } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { RunnerGoalWidget, useRunnerGoalControl } from "./RunnerGoalWidget";
vi.mock("@/api/issues", () => ({ issuesApi: { getRunnerGoal: vi.fn(), actOnRunnerGoal: vi.fn() } }));
vi.mock("@/context/LiveUpdatesProvider", () => ({ useCompanyLiveEvent: vi.fn() }));
const projection: RunnerGoalProjection = {
issueId: "issue-goal", agentId: "agent-goal", adapterType: "paperclip_runner", sessionId: "session-goal",
capability: { availability: "available", verified: true, actions: ["set", "pause", "resume", "clear"], autonomousUpdates: true,
persistentAcrossResume: true, maxObjectiveChars: 4_000, tokenBudgetControl: true, usageReporting: true },
goal: { objective: "Original objective", status: "active", tokenBudget: null, tokensUsed: 20, elapsedSeconds: 2,
iterations: 1, lastReason: null, createdAt: "2026-09-08T00:00:00Z", updatedAt: "2026-09-08T00:00:02Z",
completedAt: null, workingNow: false },
workingNow: false, activeRunId: null, pendingAction: null, revision: 7, observedAt: "2026-09-08T00:00:02Z",
};
function Harness({ agentId = "agent-goal" }: { agentId?: string }) {
const control = useRunnerGoalControl("issue-goal", agentId);
const [commandError, setCommandError] = useState<string | null>(null);
return <>
<button onClick={() => void control.executeComposerCommand({ action: "create", objective: "Replacement objective" }).catch((error) => setCommandError(error.message))}>Request replacement</button>
<button onClick={() => void control.executeComposerCommand({ action: "focus" }).catch((error) => setCommandError(error.message))}>Focus goal</button>
{commandError ? <p role="alert">{commandError}</p> : null}
<RunnerGoalWidget control={control} />
</>;
}
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
function button(name: string) {
const element = [...document.querySelectorAll<HTMLButtonElement>("button")].find((node) =>
(node.getAttribute("aria-label") ?? node.textContent?.trim()) === name);
if (!element) throw new Error(`Missing button: ${name}`);
return element;
}
async function click(name: string) { await act(async () => { button(name).click(); }); }
async function objective(value: string) {
await act(async () => {
const field = document.querySelector("textarea")!;
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(field, value);
field.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function render(agentId = "agent-goal") {
await act(async () => {
root.render(<QueryClientProvider client={client}><Harness agentId={agentId} /></QueryClientProvider>);
});
}
beforeEach(async () => {
vi.clearAllMocks();
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
client.setQueryData(queryKeys.issues.runnerGoal("issue-goal", "agent-goal"), projection);
vi.mocked(issuesApi.getRunnerGoal).mockResolvedValue(projection);
vi.mocked(issuesApi.actOnRunnerGoal).mockResolvedValue({ projection, accepted: true } as never);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await render();
});
afterEach(async () => {
await act(async () => root.unmount());
client.clear();
container.remove();
});
describe("session goal dialogs", () => {
it("hides the widget after an expanded goal is cleared", async () => {
await click("Focus goal");
vi.mocked(issuesApi.actOnRunnerGoal).mockResolvedValue({
accepted: true, projection: { ...projection, goal: null, revision: 8 },
} as never);
await click("Clear goal");
await vi.waitFor(() => expect(document.querySelector('[data-testid="runner-goal-widget"]')).toBeNull());
});
it("does not show an empty card when /goal has no current goal to focus", async () => {
await act(async () => {
client.setQueryData(queryKeys.issues.runnerGoal("issue-goal", "agent-goal"), { ...projection, goal: null });
});
await vi.waitFor(() => expect(document.querySelector('[data-testid="runner-goal-widget"]')).toBeNull());
await click("Focus goal");
expect(document.querySelector('[data-testid="runner-goal-widget"]')).toBeNull();
expect(document.querySelector('[role="alert"]')?.textContent).toContain("Add an objective after /goal");
expect(issuesApi.actOnRunnerGoal).not.toHaveBeenCalled();
});
it("keeps a pending goal action visible without a goal snapshot", async () => {
await act(async () => {
client.setQueryData(queryKeys.issues.runnerGoal("issue-goal", "agent-goal"), {
...projection, goal: null, pendingAction: "starting",
});
});
await vi.waitFor(() => expect(document.querySelector('[data-testid="runner-goal-widget"]')?.textContent).toContain("Starting"));
});
it("shows action errors without leaving behind an empty informational card", async () => {
await act(async () => {
client.setQueryData(queryKeys.issues.runnerGoal("issue-goal", "agent-goal"), { ...projection, goal: null });
});
await vi.waitFor(() => expect(document.querySelector('[data-testid="runner-goal-widget"]')).toBeNull());
vi.mocked(issuesApi.actOnRunnerGoal).mockRejectedValue(new Error("Session is unavailable."));
await click("Request replacement");
await vi.waitFor(() => expect(document.querySelector('[data-testid="runner-goal-widget"]')?.textContent).toContain("Session is unavailable."));
});
it("opens a prefilled in-app editor and saves with the reviewed revision", async () => {
await click("Edit goal");
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Edit session goal");
expect(document.querySelector("textarea")?.value).toBe("Original objective");
await objective("Revised objective");
await click("Save goal");
expect(issuesApi.actOnRunnerGoal).toHaveBeenCalledWith("issue-goal", expect.objectContaining({
action: "edit", objective: "Revised objective", expectedRevision: 7, agentId: "agent-goal",
}));
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
});
it("requires explicit replacement confirmation and lets cancellation preserve the goal", async () => {
await click("Request replacement");
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Replace session goal?");
expect(document.querySelector("textarea")?.value).toBe("Replacement objective");
expect(issuesApi.actOnRunnerGoal).not.toHaveBeenCalled();
await click("Cancel");
expect(issuesApi.actOnRunnerGoal).not.toHaveBeenCalled();
await click("Request replacement");
await click("Replace goal");
expect(issuesApi.actOnRunnerGoal).toHaveBeenCalledWith("issue-goal", expect.objectContaining({
action: "replace", objective: "Replacement objective", confirmReplace: true, expectedRevision: 7,
}));
});
it("rejects whitespace and retains edited text with a visible server error", async () => {
vi.mocked(issuesApi.actOnRunnerGoal).mockRejectedValue(new Error("Goal changed; reopen the editor to review the latest goal."));
await click("Edit goal");
await objective(" ");
expect(button("Save goal").disabled).toBe(true);
await objective("Keep my draft");
await click("Save goal");
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Goal changed"));
expect(document.querySelector("textarea")?.value).toBe("Keep my draft");
await click("Cancel");
await click("Edit goal");
expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain("Goal changed");
await click("Cancel");
await click("Request replacement");
expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain("Goal changed");
});
it("dismisses an editor when the selected agent changes", async () => {
await click("Edit goal");
await render("another-agent");
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(issuesApi.actOnRunnerGoal).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,321 @@
import { useCallback, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Flag, Loader2, Pause, Play, Pencil, Trash2 } from "lucide-react";
import type {
RunnerGoalAction,
RunnerGoalActionRequest,
RunnerGoalProjection,
} from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { useCompanyLiveEvent } from "@/context/LiveUpdatesProvider";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import type { RunnerGoalComposerCommand } from "./TaskChatComposer";
const PENDING_LABELS: Record<string, string> = {
starting: "Starting",
editing: "Saving",
replacing: "Replacing",
pausing: "Pausing after current turn",
resuming: "Resuming",
clearing: "Clearing",
continuing: "Continuing in a new run",
};
function requestId() {
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `goal_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function formatDuration(seconds: number) {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
function formatTokens(tokens: number) {
return tokens >= 1_000 ? `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}k` : String(tokens);
}
export function useRunnerGoalControl(issueId: string | null, agentId: string | null) {
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(false);
const [dialog, setDialog] = useState<{
action: "edit" | "replace";
objective: string;
revision: number;
} | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setExpanded(false);
setDialog(null);
setActionError(null);
}, [issueId, agentId]);
const key = queryKeys.issues.runnerGoal(issueId ?? "__none__", agentId);
const query = useQuery({
queryKey: key,
queryFn: () => issuesApi.getRunnerGoal(issueId!, agentId),
enabled: Boolean(issueId),
refetchOnReconnect: true,
refetchOnWindowFocus: true,
refetchInterval: (state) => state.state.data?.goal?.status === "active" ? 30_000 : false,
});
const mutation = useMutation({
mutationFn: (request: RunnerGoalActionRequest) => issuesApi.actOnRunnerGoal(issueId!, request),
onSuccess: (accepted) => {
queryClient.setQueryData(key, accepted.projection);
setExpanded(accepted.projection.goal != null);
},
});
useCompanyLiveEvent((event) => {
if (event.type !== "agent.session.goal.changed") return;
const next = event.payload as unknown as RunnerGoalProjection;
if (next.issueId !== issueId || (agentId && next.agentId !== agentId)) return;
const current = queryClient.getQueryData<RunnerGoalProjection>(key);
if (current && next.revision > current.revision + 1) {
void query.refetch();
return;
}
if (!current || next.revision >= current.revision) queryClient.setQueryData(key, next);
});
const executeAction = useCallback(async (
action: RunnerGoalAction,
objective?: string,
confirmReplace = false,
expectedRevision?: number,
) => {
setActionError(null);
try {
const current = query.data ?? (await query.refetch()).data;
if (!current?.agentId) throw new Error(current?.capability.reason ?? "Select an agent to use /goal.");
if (current.capability.availability !== "available") {
throw new Error(current.capability.reason ?? "Session goals are unsupported by this agent.");
}
await mutation.mutateAsync({
requestId: requestId(),
agentId: current.agentId,
expectedRevision: expectedRevision ?? current.revision,
action,
...(objective ? { objective } : {}),
...(action === "replace" ? { confirmReplace } : {}),
});
} catch (error) {
setActionError(error instanceof Error ? error.message : "The goal action could not be applied.");
throw error;
}
}, [mutation, query]);
const edit = useCallback(async () => {
const current = query.data ?? (await query.refetch()).data;
if (!current?.goal) throw new Error("There is no current session goal to edit.");
mutation.reset();
setActionError(null);
setExpanded(true);
setDialog({ action: "edit", objective: current.goal.objective, revision: current.revision });
}, [query, mutation]);
const executeComposerCommand = useCallback(async (command: RunnerGoalComposerCommand) => {
if (command.action === "focus") {
const current = query.data ?? (await query.refetch()).data;
if (!current?.goal && !current?.pendingAction) {
throw new Error("Add an objective after /goal to start a goal.");
}
setExpanded(true);
return;
}
if (command.action === "edit") {
await edit();
return;
}
if (command.action === "create") {
const current = query.data ?? (await query.refetch()).data;
const unfinished = current?.goal && current.goal.status !== "complete";
if (unfinished) {
mutation.reset();
setActionError(null);
setExpanded(true);
setDialog({ action: "replace", objective: command.objective, revision: current.revision });
} else {
await executeAction("create", command.objective);
}
return;
}
await executeAction(command.action);
}, [edit, executeAction, query, mutation]);
const submitDialog = async () => {
if (!dialog || mutation.isPending) return;
const objective = dialog.objective.trim();
if (!objective || objective.length > 4_000) return;
try {
await executeAction(dialog.action, objective, dialog.action === "replace", dialog.revision);
setDialog(null);
} catch {
// Keep the objective and the inline error available for correction.
}
};
return {
...query,
expanded,
setExpanded,
dialog,
setDialog,
submitDialog,
actionError,
mutation,
executeAction,
edit,
executeComposerCommand,
};
}
export type RunnerGoalControl = ReturnType<typeof useRunnerGoalControl>;
export function RunnerGoalWidget({ control }: { control: RunnerGoalControl }) {
const projection = control.data;
const goal = projection?.goal ?? null;
const capability = projection?.capability;
const can = (action: "set" | "pause" | "resume" | "clear") =>
capability?.availability === "available" && capability.actions.includes(action);
const resumable = goal && ["paused", "blocked", "limited", "usage_limited"].includes(goal.status);
const pendingLabel = projection?.pendingAction ? PENDING_LABELS[projection.pendingAction] : null;
const mutationError = control.actionError ?? (control.mutation?.error instanceof Error
? control.mutation.error.message
: control.mutation?.error
? "The goal action could not be applied."
: null);
// Expansion controls the objective's detail, not whether an empty card exists.
// In particular, a cleared goal must disappear even after it was expanded.
if (!goal && !projection?.pendingAction && !control.dialog && !mutationError) return null;
return (
<section
className="rounded-xl border border-border/80 bg-card/95 px-3 py-2 shadow-sm"
aria-label="Agent session goal"
data-testid="runner-goal-widget"
>
<div className="flex items-start gap-2">
<Flag className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold">Session goal</span>
{goal ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium capitalize" role="status">
{goal.status.replaceAll("_", " ")}
</span>
) : null}
{goal?.workingNow ? (
<span className="inline-flex items-center gap-1 text-xs text-primary" role="status">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary" /> Working now
</span>
) : null}
{pendingLabel ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground" role="status">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden /> {pendingLabel}
</span>
) : null}
</div>
{goal ? (
<p className={cn("mt-1 text-sm leading-snug", control.expanded ? "max-h-40 overflow-auto" : "line-clamp-2")}>{goal.objective}</p>
) : (
<p className="mt-1 text-xs text-muted-foreground">
{capability?.reason ?? "Type /goal followed by an objective to pursue work across turns."}
</p>
)}
{goal ? (
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{formatDuration(goal.elapsedSeconds)}</span>
{capability?.usageReporting ? (
<span>
{formatTokens(goal.tokensUsed)} tokens
{goal.tokenBudget ? ` / ${formatTokens(goal.tokenBudget)}` : ""}
</span>
) : null}
{goal.iterations > 0 ? <span>{goal.iterations} iterations</span> : null}
{goal.lastReason ? <span className="truncate">{goal.lastReason}</span> : null}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{goal && can("set") ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => void control.edit().catch(() => {})} aria-label="Edit goal">
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
) : null}
{goal?.status === "active" && can("pause") ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => void control.executeAction("pause").catch(() => {})} aria-label="Pause goal">
<Pause className="h-3.5 w-3.5" aria-hidden />
</Button>
) : null}
{resumable && can("resume") ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => void control.executeAction("resume").catch(() => {})} aria-label="Resume goal">
<Play className="h-3.5 w-3.5" aria-hidden />
</Button>
) : null}
{goal && can("clear") ? (
<Button size="icon" variant="ghost" className={cn("h-7 w-7", "text-muted-foreground hover:text-destructive")} onClick={() => void control.executeAction("clear").catch(() => {})} aria-label="Clear goal">
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</Button>
) : null}
</div>
</div>
{mutationError ? (
<p className="mt-1 text-xs text-destructive" role="alert">{mutationError}</p>
) : null}
<Dialog open={Boolean(control.dialog)} onOpenChange={(open) => {
if (!open && !control.mutation?.isPending) control.setDialog(null);
}}>
<DialogContent showCloseButton={!control.mutation?.isPending}>
<form className="space-y-4" onSubmit={(event) => {
event.preventDefault();
void control.submitDialog();
}}>
<DialogHeader>
<DialogTitle>{control.dialog?.action === "replace" ? "Replace session goal?" : "Edit session goal"}</DialogTitle>
<DialogDescription>
{control.dialog?.action === "replace"
? "This clears the unfinished goal and starts a new goal with the objective below."
: "Update the objective without clearing the goal's progress."}
</DialogDescription>
</DialogHeader>
<label className="block space-y-2">
<span className="text-sm font-medium">Goal objective</span>
<Textarea
value={control.dialog?.objective ?? ""}
maxLength={4_000}
required
disabled={control.mutation?.isPending}
onChange={(event) => {
const objective = event.target.value;
control.setDialog((current) => current ? { ...current, objective } : null);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
</label>
{mutationError ? <p className="text-sm text-destructive" role="alert">{mutationError}</p> : null}
<DialogFooter>
<Button type="button" variant="outline" disabled={control.mutation?.isPending} onClick={() => control.setDialog(null)}>Cancel</Button>
<Button type="submit" disabled={!control.dialog?.objective.trim() || control.mutation?.isPending}>
{control.mutation?.isPending ? "Saving…" : control.dialog?.action === "replace" ? "Replace goal" : "Save goal"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
);
}
@@ -3,8 +3,47 @@ import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatExpansionState } from "./expansion-state";
import type { TaskChatToolItem } from "./task-chat-model";
describe("TaskChatActivityPhase", () => {
it("retains expanded phase and tool details when live activity becomes persisted history", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const memory = new Map<string, boolean>();
const render = (host: string, status: TaskChatToolItem["status"]) => flushSync(() => root.render(
<TaskChatExpansionState.Provider value={memory}>
<TaskChatActivityPhase
key={host}
autoOpen={false}
item={{ id: "phase-1", kind: "activity_phase", active: status === "in_progress", summary: "Checked source", items: [
{ id: "tool-1", kind: "tool", name: "Read", status, detail: "Source contents stay visible" },
] }}
renderChild={(child) => <TaskChatToolCard item={child as TaskChatToolItem} />}
/>
</TaskChatExpansionState.Provider>,
));
render("live", "in_progress");
flushSync(() => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]')!.click());
flushSync(() => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-tool-card"] button')!.click());
render("history", "completed");
expect(container.querySelector('[data-testid="task-chat-phase-summary"]')?.getAttribute("aria-expanded")).toBe("true");
expect(container.querySelector('[data-testid="task-chat-tool-card"] button')?.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("Source contents stay visible");
flushSync(() => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]')!.click());
flushSync(() => root.render(
<TaskChatExpansionState.Provider value={memory}>
<TaskChatActivityPhase key="reconciled" defaultOpen item={{ id: "phase-1", kind: "activity_phase", active: false, summary: "Checked source", items: [
{ id: "tool-1", kind: "tool", name: "Read", status: "completed" },
] }} renderChild={() => null} />
</TaskChatExpansionState.Provider>,
));
expect(container.querySelector('[data-testid="task-chat-phase-summary"]')?.getAttribute("aria-expanded")).toBe("false");
flushSync(() => root.unmount());
});
afterEach(() => {
document.body.innerHTML = "";
});
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { TaskChatExpansionState, useTaskChatExpansion } from "./expansion-state";
import { useContext, useEffect, type ReactNode } from "react";
import { Brain, ChevronRight, CircleEllipsis } from "lucide-react";
import { cn } from "@/lib/utils";
import { MarkdownBody } from "@/components/MarkdownBody";
@@ -62,10 +63,11 @@ export function TaskChatActivityPhase({
child.surface === "runtime_request" &&
child.status === "pending"),
)));
const [open, setOpen] = useState(shouldAutoOpen);
const [open, setOpen] = useTaskChatExpansion(item.id, shouldAutoOpen);
const expansionMemory = useContext(TaskChatExpansionState);
useEffect(() => {
if (shouldAutoOpen) setOpen(true);
}, [shouldAutoOpen]);
if (shouldAutoOpen && !expansionMemory?.has(item.id)) setOpen(true);
}, [shouldAutoOpen, expansionMemory, item.id, setOpen]);
const expandable = item.items.length > 0;
const runnerAppearance = appearance === "runner";
const SummaryIcon = runnerAppearance ? representativeIcon(item) : null;
@@ -4,8 +4,9 @@ import type { ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { TaskChatBubble } from "./TaskChatBubble";
import type { TaskChatMessageItem } from "./task-chat-model";
@@ -40,6 +41,23 @@ describe("TaskChatBubble attachment chips", () => {
);
}
it("opens attachment images in the shared task gallery", () => {
const openGallery = vi.fn(() => true);
const contentPath = "/api/attachments/shared-image/content";
flushSync(() => root!.render(
<ThemeProvider>
<IssueGalleryContext.Provider value={openGallery}>
<TaskChatBubble item={{ id: "m1", kind: "message", author: "agent", text: `![Proof](${contentPath})` }} />
</IssueGalleryContext.Provider>
</ThemeProvider>,
));
const image = container.querySelector<HTMLImageElement>(`img[src="${contentPath}"]`);
expect(image).not.toBeNull();
flushSync(() => image!.click());
expect(openGallery).toHaveBeenCalledWith(contentPath);
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("renders a file reference as an attachment chip linking to the file", () => {
renderMessage("Here you go.\n\n[notes.txt](/api/attachments/abc/content) ");
@@ -130,6 +148,60 @@ describe("TaskChatBubble attachment chips", () => {
const group = container.querySelector('[data-testid="task-chat-bubble-attachments"]');
expect(group?.textContent).toContain("Log · 14.0 KB");
});
it("renders only the provider attachments bound to this comment without Markdown refs", () => {
renderMessage("Please inspect both files.", "human", [
attachment({
id: "photo",
originalFilename: "evidence.png",
contentType: "image/png",
byteSize: 4096,
}),
attachment({
id: "notes",
originalFilename: "notes.txt",
contentType: "text/plain",
byteSize: 128,
}),
attachment({
id: "other-comment",
issueCommentId: "m2",
originalFilename: "unrelated.txt",
contentType: "text/plain",
}),
]);
expect(
container.querySelector('[data-testid="task-chat-bubble-media"] img')
?.getAttribute("alt"),
).toBe("evidence.png");
expect(container.textContent).toContain("Images · 1");
const group = container.querySelector(
'[data-testid="task-chat-bubble-attachments"]',
);
expect(group?.textContent).toContain("notes.txt");
expect(group?.textContent).not.toContain("unrelated.txt");
});
it("does not duplicate a bound attachment already referenced in the comment", () => {
renderMessage(
"[notes.txt](/api/attachments/notes/content)",
"human",
[
attachment({
id: "notes",
originalFilename: "notes.txt",
contentType: "text/plain",
}),
],
);
const group = container.querySelector(
'[data-testid="task-chat-bubble-attachments"]',
);
expect(group?.querySelectorAll("a")).toHaveLength(1);
expect(container.textContent).toContain("Files · 1");
});
});
function attachment(overrides: Partial<IssueAttachment>): IssueAttachment {
+42 -14
View File
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from "react";
import { useCallback, useContext, useState, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import { MarkdownBody } from "@/components/MarkdownBody";
@@ -27,6 +28,7 @@ import {
hydrateAttachmentRefs,
isImageAttachment,
stripStandaloneImageEmbeds,
type AttachmentRef,
} from "./task-chat-attachments";
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
import type { TaskChatMessageItem } from "./task-chat-model";
@@ -128,6 +130,16 @@ function galleryItemForImage(
};
}
function uniqueAttachmentRefs(refs: AttachmentRef[]): AttachmentRef[] {
return refs.filter(
(ref, index) =>
refs.findIndex(
(candidate) =>
(ref.id && candidate.id === ref.id) || candidate.url === ref.url,
) === index,
);
}
export function TaskChatBubble({
item,
animateEntry = true,
@@ -141,9 +153,14 @@ export function TaskChatBubble({
tryAgainNoLiveExecutionPathPending,
}: TaskChatBubbleProps) {
const streamlined = useStreamlinedTaskChatPresentation();
// Clicking an embedded image opens the full-screen lightbox (with download);
// arrow keys walk across the other images in the same bubble.
// Task attachments share the page gallery; standalone images retain the bubble viewer.
const openIssueGallery = useContext(IssueGalleryContext);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
// Keep MarkdownBody's memo boundary intact when only the live tail changes.
// A fresh callback here reparses every historical response on every update.
const openImage = useCallback((src: string) => {
if (!openIssueGallery?.(src)) setLightboxSrc(src);
}, [openIssueGallery]);
if (item.interstitial) {
// Interstitial updates are ephemeral (PAP-361): while streaming the text
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
@@ -175,15 +192,26 @@ export function TaskChatBubble({
embeddedImageRefs,
attachments,
);
const imageRefs = [
const boundAttachmentRefs: AttachmentRef[] = attachments
.filter((attachment) => attachment.issueCommentId === item.id)
.map((attachment) => ({
id: attachment.id,
name: attachment.originalFilename?.trim() || "attachment",
url: attachment.contentPath,
contentType: attachment.contentType,
byteSize: attachment.byteSize,
openPath: attachment.openPath,
downloadPath: attachment.downloadPath,
}));
const imageRefs = uniqueAttachmentRefs([
...hydratedEmbeddedRefs,
...hydratedLinkedRefs.filter(isImageAttachment),
].filter((ref, index, refs) =>
refs.findIndex((candidate) => candidate.url === ref.url) === index,
);
const attachmentRefs = hydratedLinkedRefs.filter(
(ref) => !isImageAttachment(ref),
);
...boundAttachmentRefs.filter(isImageAttachment),
]);
const attachmentRefs = uniqueAttachmentRefs([
...hydratedLinkedRefs.filter((ref) => !isImageAttachment(ref)),
...boundAttachmentRefs.filter((ref) => !isImageAttachment(ref)),
]);
const galleryItems: GalleryMediaItem[] =
lightboxSrc !== null && !imageRefs.some((ref) => ref.url === lightboxSrc)
? // A clicked image the extractor missed (e.g. inline HTML) still gets a
@@ -235,7 +263,7 @@ export function TaskChatBubble({
className={isHuman ? "paperclip-markdown-on-accent" : undefined}
softBreaks
linkIssueReferences
onImageClick={setLightboxSrc}
onImageClick={openImage}
>
{bodyText}
</MarkdownBody>
@@ -247,7 +275,7 @@ export function TaskChatBubble({
data-testid="task-chat-bubble-media"
>
<span className="text-xs text-muted-foreground">
Screenshots · {imageRefs.length}
Images · {imageRefs.length}
</span>
<div className="grid grid-cols-4 gap-2">
{imageRefs
@@ -258,7 +286,7 @@ export function TaskChatBubble({
type="button"
className="group aspect-video min-w-0 overflow-hidden rounded-md bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Open ${ref.name || `image ${index + 1}`}`}
onClick={() => setLightboxSrc(ref.url)}
onClick={() => openImage(ref.url)}
>
<img
src={ref.openPath ?? ref.url}
@@ -273,7 +301,7 @@ export function TaskChatBubble({
type="button"
className="aspect-video min-w-0 rounded-md bg-muted text-sm font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Open ${imageRefs.length - 3} more screenshots`}
onClick={() => setLightboxSrc(imageRefs[3].url)}
onClick={() => openImage(imageRefs[3].url)}
>
+{imageRefs.length - 3}
</button>
File diff suppressed because it is too large. Load diff
+552 -72
View File
@@ -8,15 +8,24 @@ import {
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { useComposerStop } from "@/hooks/useComposerStop";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import {
DRAFT_DEBOUNCE_MS,
clearDraft,
loadDraft,
loadDraftAttachments,
saveDraft,
saveDraftAttachments,
loadDraftSubmission,
saveDraftSubmission,
clearDraftSubmission,
type ComposerDraftSubmission,
} from "@/lib/composer-draft";
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
import {
ArrowUp,
Square,
Check,
ChevronDown,
CircleHelp,
@@ -59,10 +68,14 @@ import { AgentIcon } from "@/components/AgentIconPicker";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { MentionOption } from "@/components/MarkdownEditor";
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
import type { RunnerGoalCapability } from "@paperclipai/shared";
import type { ActionCommandOption } from "@/context/EditorAutocompleteContext";
import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeoverContext";
import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover";
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
interface CommentReassignment {
export interface CommentReassignment {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
@@ -78,6 +91,7 @@ export interface TaskChatComposerTakeover {
onShowNext?: () => void;
/** Places Skip inside a structured question form's action row. */
inlineSkip?: boolean;
hideLabel?: boolean;
/** Some decision surfaces already provide a non-accept path of their own. */
hideSkip?: boolean;
}
@@ -87,7 +101,11 @@ interface TaskChatComposerProps {
body: string,
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
) => Promise<void> | void;
onStop?: () => Promise<void>;
stopPending?: boolean;
stopScope?: "leaf" | "subtree";
workMode: IssueWorkMode;
onWorkModeChange?: (mode: IssueWorkMode) => Promise<void> | void;
disabled?: boolean;
@@ -107,21 +125,94 @@ interface TaskChatComposerProps {
{ label: string; image: string | null }
> | null;
currentAssigneeValue?: string;
onPendingAssigneeChange?: (value: string | null) => void;
issueStatus?: string;
/** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */
mobile?: boolean;
/** Storage key used to restore, persist, and clear this task's text draft. */
draftKey?: string;
onReviewConversation?: () => Promise<void>;
/** When set, the main composer temporarily edits this queued message. */
queuedEdit?: { commentId: string; body: string; stale?: boolean } | null;
onSaveQueuedEdit?: (commentId: string, body: string) => Promise<void>;
onCancelQueuedEdit?: () => void;
pause?: TaskComposerPause | null;
takeover?: TaskChatComposerTakeover | null;
pendingTakeover?: {
count: number;
label?: string;
onOpen: () => void;
} | null;
runnerGoalCapability?: RunnerGoalCapability | null;
onRunnerGoalCommand?: (
command: RunnerGoalComposerCommand,
) => Promise<void> | void;
onRunnerGoalReassign?: (
reassignment: CommentReassignment,
) => Promise<void> | void;
}
export type RunnerGoalComposerCommand =
| { action: "focus" }
| { action: "create"; objective: string }
| { action: "edit" }
| { action: "pause" }
| { action: "resume" }
| { action: "clear" };
export type ParsedRunnerGoalCommand =
| { matched: false }
| { matched: true; command: RunnerGoalComposerCommand }
| { matched: true; error: string };
function normalizeRunnerGoalCommandText(value: string): string {
const trimmed = value.trim();
// MDXEditor's link extension can reinterpret a selected action command plus
// its subsequently typed argument as one relative autolink. Accept only the
// exact whole-document shape it generates so the action still cannot fall
// through as a comment. Ordinary Markdown links remain ordinary comments.
const relativeAutolink = trimmed.match(
/^\[\/(?:go(?:al)?)?[ \t\u00a0]*\]\(<(\/goal(?:[ \t\u00a0].*)?)>\)$/s,
);
if (relativeAutolink) return relativeAutolink[1]!.replaceAll("\u00a0", " ");
const encodedAutolink = trimmed.match(
/^\[\/(?:go(?:al)?)?[ \t\u00a0]*\]\((\/goal(?:%20|%C2%A0).*)\)$/s,
);
if (encodedAutolink) {
try {
return decodeURIComponent(encodedAutolink[1]!).replaceAll("\u00a0", " ");
} catch {
return trimmed;
}
}
return trimmed.replaceAll("\u00a0", " ");
}
export function parseRunnerGoalCommand(value: string): ParsedRunnerGoalCommand {
const trimmed = normalizeRunnerGoalCommandText(value);
if (!trimmed) return { matched: false };
const firstWhitespace = trimmed.search(/\s/);
const firstToken =
firstWhitespace === -1 ? trimmed : trimmed.slice(0, firstWhitespace);
if (firstToken !== "/goal") return { matched: false };
const remainder =
firstWhitespace === -1 ? "" : trimmed.slice(firstWhitespace).trim();
if (!remainder) return { matched: true, command: { action: "focus" } };
const [subcommand, ...extra] = remainder.split(/\s+/);
if (["edit", "pause", "resume", "clear"].includes(subcommand)) {
if (extra.length > 0) {
return {
matched: true,
error: `/goal ${subcommand} does not accept extra arguments.`,
};
}
return {
matched: true,
command: { action: subcommand as "edit" | "pause" | "resume" | "clear" },
};
}
return { matched: true, command: { action: "create", objective: remainder } };
}
/** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */
@@ -210,7 +301,12 @@ const MODE_DESCRIPTION: Partial<Record<IssueWorkMode, string>> = {
};
/** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */
function modePlaceholder(mode: IssueWorkMode, agentName: string): string {
function modePlaceholder(mode: IssueWorkMode, agentName: string, mobile: boolean): string {
if (mobile) {
if (mode === "planning") return `Plan with ${agentName}…`;
if (mode === "ask") return `Ask ${agentName}…`;
return `Message ${agentName}…`;
}
switch (mode) {
case "planning":
return `Plan with ${agentName} — shapes the plan doc, no code changes…`;
@@ -223,6 +319,8 @@ function modePlaceholder(mode: IssueWorkMode, agentName: string): string {
type ComposerAttachment = {
id: string;
attachmentId?: string;
inline?: boolean;
name: string;
size?: number;
status: "uploading" | "attached" | "error";
@@ -281,6 +379,9 @@ function escapeMarkdownLabel(name: string): string {
*/
export function TaskChatComposer({
onAdd,
onStop,
stopPending = false,
stopScope = "leaf",
workMode,
onWorkModeChange,
disabled = false,
@@ -294,18 +395,38 @@ export function TaskChatComposer({
agentMap,
userProfileMap,
currentAssigneeValue = "",
onPendingAssigneeChange,
issueStatus,
mobile = false,
draftKey,
onReviewConversation,
queuedEdit = null,
onSaveQueuedEdit,
onCancelQueuedEdit,
pause = null,
takeover = null,
pendingTakeover = null,
runnerGoalCapability = null,
onRunnerGoalCommand,
onRunnerGoalReassign,
}: TaskChatComposerProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const stopControl = useComposerStop(onStop, stopPending);
const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const [submitting, setSubmitting] = useState(false);
const [reviewError, setReviewError] = useState(false);
const [uncertainSubmission, setUncertainSubmission] =
useState<ComposerDraftSubmission | null>(() =>
draftKey ? loadDraftSubmission(draftKey) : null,
);
const mountedTaskKey = useRef(draftKey);
useEffect(() => {
mountedTaskKey.current = draftKey;
setUncertainSubmission(draftKey ? loadDraftSubmission(draftKey) : null);
return () => {
mountedTaskKey.current = undefined;
};
}, [draftKey]);
const [takeoverBusy, setTakeoverBusy] = useState(false);
const [takeoverError, setTakeoverError] = useState<string | null>(null);
const [takeoverHeaderClaimed, setTakeoverHeaderClaimed] = useState(false);
@@ -315,9 +436,30 @@ export function TaskChatComposer({
useState<HTMLElement | null>(null);
const [pendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
const [actionError, setActionError] = useState<string | null>(null);
const [attachments, setAttachmentState] = useState<ComposerAttachment[]>(
() =>
draftKey
? loadDraftAttachments(draftKey).map((item) => ({
...item,
id: `receipt:${item.attachmentId}`,
status: "attached",
}))
: [],
);
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
function setAttachments(
update:
| ComposerAttachment[]
| ((previous: ComposerAttachment[]) => ComposerAttachment[]),
) {
const next =
typeof update === "function" ? update(attachmentsRef.current) : update;
attachmentsRef.current = next;
setAttachmentState(next);
}
const submittingRef = useRef(submitting);
submittingRef.current = submitting;
const pendingAssigneeRef = useRef(pendingAssignee);
pendingAssigneeRef.current = pendingAssignee;
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -377,11 +519,46 @@ export function TaskChatComposer({
useEffect(() => {
if (!draftKey || queuedEdit) return;
setBody(loadDraft(draftKey));
bodyRef.current = loadDraft(draftKey);
setBody(bodyRef.current);
}, [draftKey, queuedEdit]);
useEffect(() => {
if (!draftKey || queuedEdit) {
if (!draftKey) return;
setAttachments(
loadDraftAttachments(draftKey).map((item) => ({
...item,
id: `receipt:${item.attachmentId}`,
status: "attached",
})),
);
}, [draftKey]);
useEffect(() => {
if (
!draftKey ||
queuedEdit ||
submitting ||
uncertainSubmission ||
attachments !== attachmentsRef.current
)
return;
saveDraftAttachments(
draftKey,
attachments
.filter((item) => item.status === "attached" && item.attachmentId)
.map((item) => ({
attachmentId: item.attachmentId,
name: item.name,
size: item.size,
inline: item.inline === true,
contentPath: item.contentPath,
})),
);
}, [attachments, draftKey, queuedEdit, submitting]);
useEffect(() => {
if (!draftKey || queuedEdit || submitting) {
if (draftTimer.current) {
clearTimeout(draftTimer.current);
draftTimer.current = null;
@@ -392,12 +569,12 @@ export function TaskChatComposer({
draftTimer.current = setTimeout(() => {
saveDraft(draftKey, body);
}, DRAFT_DEBOUNCE_MS);
}, [body, draftKey, queuedEdit]);
}, [body, draftKey, queuedEdit, submitting]);
useEffect(() => {
return () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
if (draftKey && !queuedEditRef.current)
if (draftKey && !queuedEditRef.current && !submittingRef.current)
saveDraft(draftKey, bodyRef.current);
};
}, [draftKey]);
@@ -405,14 +582,18 @@ export function TaskChatComposer({
useEffect(() => {
if (!draftKey) return;
const flushDraft = () => {
if (!queuedEditRef.current) saveDraft(draftKey, bodyRef.current);
if (!queuedEditRef.current && !submittingRef.current)
saveDraft(draftKey, bodyRef.current);
};
window.addEventListener("beforeunload", flushDraft);
return () => window.removeEventListener("beforeunload", flushDraft);
}, [draftKey]);
const modeMeta = workModeMetaFor(pendingMode);
const canAcceptFiles = !queuedEdit && Boolean(onAttachImage || onImageUpload);
const canAcceptFiles =
!queuedEdit &&
!uncertainSubmission &&
Boolean(onAttachImage || onImageUpload);
const showAssignee = Boolean(
enableReassign && reassignOptions && reassignOptions.length > 0,
);
@@ -423,17 +604,77 @@ export function TaskChatComposer({
assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel;
const effectivePlaceholder = queuedEdit
? "Edit queued message…"
: (placeholder ?? modePlaceholder(pendingMode, assigneeName));
: (placeholder ?? modePlaceholder(pendingMode, assigneeName, mobile));
const goalUnavailable = runnerGoalCapability?.availability !== "available";
const goalCommandOption: ActionCommandOption = {
id: "action:goal",
kind: "action",
command: "goal",
name: "Goal",
description:
!runnerGoalCapability || runnerGoalCapability.verified === false
? "Support will be verified when the session starts."
: "Pursue work across turns.",
aliases: ["goal", "pursue", "continue"],
disabled: goalUnavailable && runnerGoalCapability !== null,
disabledReason:
runnerGoalCapability?.reason ??
"Session goals are unsupported by this agent.",
};
function updatePendingAssignee(value: string | null) {
setPendingAssignee(value);
onPendingAssigneeChange?.(value);
}
/** Upload an image and return its URL for inline `![](src)` markdown. */
async function uploadInlineImage(file: File): Promise<string> {
if (onAttachImage) {
const attachment = await onAttachImage(file);
if (attachment?.contentPath) return attachment.contentPath;
throw new Error("Upload did not return a file URL");
const id = crypto.randomUUID();
setAttachments((prev) => [
...prev,
{
id,
name: file.name,
size: file.size,
inline: true,
status: "uploading",
},
]);
try {
const attachment = onAttachImage ? await onAttachImage(file) : undefined;
const url = onAttachImage
? attachment?.contentPath
: await onImageUpload?.(file);
if (!url) throw new Error("Upload did not return a file URL");
if (!attachmentsRef.current.some((item) => item.id === id))
throw new Error("Attachment was removed");
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
attachmentId: attachment?.id,
contentPath: url,
status: "attached",
}
: item,
),
);
return url;
} catch (err) {
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
status: "error",
error: err instanceof Error ? err.message : "Upload failed",
}
: item,
),
);
throw err;
}
if (onImageUpload) return onImageUpload(file);
throw new Error("This file type cannot be attached here");
}
/** Non-image files: attach to the task and track in the chip row. */
@@ -459,6 +700,8 @@ export function TaskChatComposer({
return;
}
const attachment = await onAttachImage(file);
if (!attachment?.contentPath)
throw new Error("Upload did not return a file URL");
const name = attachment?.originalFilename ?? file.name;
setAttachments((prev) =>
prev.map((item) =>
@@ -466,6 +709,7 @@ export function TaskChatComposer({
? {
...item,
name,
attachmentId: attachment?.id,
status: "attached",
contentPath: attachment?.contentPath,
}
@@ -493,23 +737,13 @@ export function TaskChatComposer({
await attachNonImageFile(file);
return;
}
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
try {
const url = await uploadInlineImage(file);
editorRef.current?.insertMarkdown(
`![${escapeMarkdownLabel(file.name)}](${url})`,
);
} catch (err) {
setAttachments((prev) => [
...prev,
{
id,
name: file.name,
size: file.size,
status: "error",
error: err instanceof Error ? err.message : "Upload failed",
},
]);
} catch {
// uploadInlineImage retains the failed receipt in the removable chip row.
}
}
@@ -529,7 +763,7 @@ export function TaskChatComposer({
* the paste when it carries no images the plugin should handle.
*/
function handlePasteCapture(evt: ReactClipboardEvent<HTMLDivElement>) {
if (!canAcceptFiles) return;
if (pause || !canAcceptFiles) return;
const files = Array.from(evt.clipboardData?.files ?? []);
if (files.length === 0) return;
const nonImages = files.filter((file) => !file.type.startsWith("image/"));
@@ -546,11 +780,20 @@ export function TaskChatComposer({
// Uploaded file references ride along as trailing `[name](contentPath)`
// lines — the bubble renderer folds those link-only lines back into chips.
const attachedRefs = attachments.filter(
(item) => item.status === "attached" && item.contentPath,
(item) => item.status === "attached" && item.contentPath && !item.inline,
);
const visibleAttachments = attachments.filter(
(item) => !item.inline || item.status !== "attached",
);
// Sending mid-upload would silently drop the pending file from the comment;
// sending past a failed chip would discard the file the user selected and
// clear its error state, so both hold submission until resolved or removed.
const showStop =
!queuedEdit &&
!submitting &&
body.trim().length === 0 &&
attachments.length === 0 &&
Boolean(onStop || stopControl.stopping);
const uploadPending = attachments.some((item) => item.status === "uploading");
const uploadFailed = attachments.some((item) => item.status === "error");
const takeoverVisible = Boolean(
@@ -565,15 +808,81 @@ export function TaskChatComposer({
}, [queuedEdit, takeoverVisible]);
async function submit() {
if (pause || disabled) return;
const retained =
draftKey && !queuedEdit ? loadDraftSubmission(draftKey) : null;
if (retained && !submitting) {
setUncertainSubmission(retained);
return;
}
const submittedBody = bodyRef.current;
const submittedAttachments = attachmentsRef.current;
const submittedAssignee = pendingAssigneeRef.current;
const trimmed = submittedBody.trim();
const goalCommand = queuedEdit
? ({ matched: false } as const)
: parseRunnerGoalCommand(submittedBody);
if (goalCommand.matched) {
if ("error" in goalCommand) {
setActionError(goalCommand.error);
return;
}
if (attachmentsRef.current.length > 0) {
setActionError("Remove attachments before using /goal.");
return;
}
if (!onRunnerGoalCommand) {
setActionError(
runnerGoalCapability?.reason ??
"Session goals are unsupported by this agent.",
);
return;
}
if (
runnerGoalCapability &&
runnerGoalCapability.availability !== "available"
) {
setActionError(
runnerGoalCapability.reason ??
"Session goals are unsupported by this agent.",
);
return;
}
try {
const hasReassignment =
showAssignee && assigneeValue !== currentAssigneeValue;
if (hasReassignment && goalCommand.command.action !== "focus") {
const reassignment = parseAssigneeValue(assigneeValue);
if (!reassignment || !onRunnerGoalReassign) {
setActionError("Select an agent before starting a session goal.");
return;
}
await onRunnerGoalReassign(reassignment);
updatePendingAssignee(null);
}
await onRunnerGoalCommand(goalCommand.command);
setActionError(null);
bodyRef.current = "";
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = null;
if (draftKey) clearDraft(draftKey);
setBody("");
editorRef.current?.clear();
} catch (error) {
setActionError(
error instanceof Error
? error.message
: "The goal action could not be applied.",
);
}
return;
}
if (
(!trimmed && attachedRefs.length === 0) ||
uploadPending ||
uploadFailed ||
submitting ||
uncertainSubmission ||
disabled
)
return;
@@ -603,9 +912,18 @@ export function TaskChatComposer({
clearTimeout(draftTimer.current);
draftTimer.current = null;
}
if (draftKey) clearDraft(draftKey);
if (draftKey && !queuedEdit) {
if (
submittedAttachments.some(
(item) => item.status === "attached" && item.attachmentId,
)
)
saveDraft(draftKey, submittedBody);
else clearDraft(draftKey);
}
setBody("");
setSubmitting(true);
let attemptId: string | null = null;
try {
if (queuedEdit) {
if (!onSaveQueuedEdit) return;
@@ -616,19 +934,61 @@ export function TaskChatComposer({
if (pendingMode !== workMode && onWorkModeChange) {
await onWorkModeChange(pendingMode);
}
await onAdd(fullBody, reopen, reassignment);
const retainedAfterMode = draftKey ? loadDraftSubmission(draftKey) : null;
if (retainedAfterMode) {
setUncertainSubmission(retainedAfterMode);
setBody(submittedBody);
return;
}
attemptId = crypto.randomUUID();
if (draftKey) {
saveDraft(draftKey, submittedBody);
saveDraftSubmission(draftKey, { attemptId, reviewed: false });
}
// IDs come only from this composer's upload receipts, never from parsing
// arbitrary Markdown. A removed inline image no longer selects its receipt.
const attachmentIds = [
...new Set(
submittedAttachments
.filter(
(item) =>
item.status === "attached" &&
item.attachmentId &&
(!item.inline ||
(item.contentPath &&
submittedBody.includes(item.contentPath))),
)
.map((item) => item.attachmentId!),
),
];
if (attachmentIds.length > 0)
await onAdd(fullBody, reopen, reassignment, attachmentIds);
else await onAdd(fullBody, reopen, reassignment);
if (mountedTaskKey.current !== draftKey) return;
if (draftKey) clearDraftSubmission(draftKey, attemptId);
if (draftKey && bodyRef.current) {
// The editor stays writable while the request is pending. Preserve
// text entered after this submission started as the next draft.
saveDraft(draftKey, bodyRef.current);
} else if (draftKey) {
clearDraft(draftKey);
}
if (attachmentsRef.current === submittedAttachments) {
setAttachments([]);
}
const submittedIds = new Set(submittedAttachments.map((item) => item.id));
setAttachments((current) =>
current.filter((item) => !submittedIds.has(item.id)),
);
if (pendingAssigneeRef.current === submittedAssignee) {
setPendingAssignee(null);
updatePendingAssignee(null);
}
} catch {
} catch (error) {
if (mountedTaskKey.current !== draftKey) return;
if (attemptId && error instanceof CommentSubmissionUnknownError) {
const uncertain = { attemptId, reviewed: false };
setUncertainSubmission(uncertain);
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
saveDraftSubmission(draftKey, uncertain);
} else if (draftKey && attemptId)
clearDraftSubmission(draftKey, attemptId);
// Restore the failed message for retry without discarding a next draft
// that was entered while the request was pending.
const nextDraft = bodyRef.current;
@@ -640,13 +1000,41 @@ export function TaskChatComposer({
clearTimeout(draftTimer.current);
draftTimer.current = null;
}
if (draftKey) saveDraft(draftKey, restoredBody);
if (draftKey) saveDraft(draftKey, restoredBody, attemptId ?? undefined);
setBody(restoredBody);
} finally {
setSubmitting(false);
}
}
async function reviewUncertainSubmission() {
if (!uncertainSubmission) return;
setReviewError(false);
try {
if (!onReviewConversation) throw new Error("Review unavailable");
await onReviewConversation();
if (mountedTaskKey.current !== draftKey) return;
const reviewed = { ...uncertainSubmission, reviewed: true };
setUncertainSubmission(reviewed);
if (
draftKey &&
loadDraftSubmission(draftKey)?.attemptId === reviewed.attemptId
)
saveDraftSubmission(draftKey, reviewed);
} catch {
setReviewError(true);
}
}
function discardUncertainDraft() {
if (!uncertainSubmission?.reviewed) return;
if (draftKey) clearDraft(draftKey, uncertainSubmission.attemptId);
bodyRef.current = "";
setBody("");
setAttachments([]);
setUncertainSubmission(null);
}
function skipTakeover() {
if (!takeover) return;
setTakeoverBusy(true);
@@ -676,12 +1064,17 @@ export function TaskChatComposer({
</Button>
) : null;
if (pause) {
return <TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />;
}
return (
<div
className={cn(
streamlined
? "paperclip-task-chat-composer rounded-(--radius-task-composer) border border-border bg-card p-(--sz-18px) shadow-(--shadow-task-composer) dark:border-0 dark:bg-muted dark:shadow-none"
: "paperclip-task-chat-composer rounded-xl bg-card p-(--sz-18px)",
mobile && "p-3",
)}
onKeyDownCapture={(e) => {
// Capture mode shortcuts on the wrapper so they work while the rich
@@ -701,6 +1094,44 @@ export function TaskChatComposer({
}}
onPasteCapture={handlePasteCapture}
>
{uncertainSubmission ? (
<div
role="alert"
className="mb-3 space-y-2 rounded-md border border-border bg-muted p-3 text-sm"
>
<p>
We couldn’t confirm whether this comment was saved. It may already
be in the conversation. Review it before starting another draft.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={reviewUncertainSubmission}
>
Review conversation
</Button>
{reviewError ? (
<p>Couldn’t refresh the conversation. Try reviewing it again.</p>
) : null}
{uncertainSubmission.reviewed ? (
<>
<p>
Discarding this draft does not remove any saved comment or
uploaded file.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={discardUncertainDraft}
>
Discard draft and start new
</Button>
</>
) : null}
</div>
) : null}
{takeoverVisible && takeover ? (
<section
className="relative"
@@ -708,11 +1139,16 @@ export function TaskChatComposer({
data-testid="task-chat-composer-takeover"
>
<div
className="mb-3 flex min-w-0 items-center gap-2"
className={cn(
"flex min-w-0 items-center gap-2",
takeover.hideLabel && takeover.pendingCount === 1
? "absolute right-0 top-0 z-10"
: "mb-3",
)}
data-testid="task-chat-composer-takeover-header"
>
<div className="min-w-0 flex-1">
{!takeoverHeaderClaimed ? (
{!takeoverHeaderClaimed && !takeover.hideLabel ? (
<strong className="block truncate text-sm font-medium text-foreground">
{takeover.label}
</strong>
@@ -753,7 +1189,14 @@ export function TaskChatComposer({
</Button>
</div>
</div>
<div className="pr-1" data-testid="task-chat-composer-takeover-body">
<div
className={
takeover.hideLabel && takeover.pendingCount === 1
? "pr-8"
: "pr-1"
}
data-testid="task-chat-composer-takeover-body"
>
<TaskChatComposerTakeoverActionsContext.Provider
value={{
skipButton:
@@ -762,6 +1205,7 @@ export function TaskChatComposer({
takeoverSkipButton
? takeoverSkipButton
: null,
dismiss: takeover.onDismiss,
headerSlot: takeoverHeaderSlot,
controlsSlot: takeoverControlsSlot,
setHeaderClaimed: setTakeoverHeaderClaimed,
@@ -809,8 +1253,9 @@ export function TaskChatComposer({
? (disabledReason ?? "Composer disabled")
: effectivePlaceholder
}
readOnly={disabled}
readOnly={disabled || !!uncertainSubmission}
mentions={mentions}
actionCommands={[goalCommandOption]}
onSubmit={() => void submit()}
imageUploadHandler={
canAcceptFiles ? uploadInlineImage : undefined
@@ -820,18 +1265,28 @@ export function TaskChatComposer({
className={cn(disabled && "opacity-60")}
contentClassName={
mobile
? "max-h-(--sz-28dvh) min-h-(--sz-72px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
}
/>
</div>
{attachments.length > 0 ? (
{actionError ? (
<p
className="px-1 text-xs text-destructive"
role="alert"
data-testid="task-chat-goal-error"
>
{actionError}
</p>
) : null}
{visibleAttachments.length > 0 ? (
<AttachmentGroup
className="mb-1 px-1"
data-testid="task-chat-composer-attachments"
>
{attachments.map((attachment) => {
{visibleAttachments.map((attachment) => {
const kind = fileKindForName(attachment.name);
const KindIcon = kind.icon;
const sizeLabel = formatFileSize(attachment.size);
@@ -871,6 +1326,7 @@ export function TaskChatComposer({
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${attachment.name}`}
disabled={!!uncertainSubmission}
onClick={() =>
setAttachments((prev) =>
prev.filter((item) => item.id !== attachment.id),
@@ -933,6 +1389,7 @@ export function TaskChatComposer({
)}
style={{ "--sc": modeHue(pendingMode) } as CSSProperties}
data-testid="task-chat-composer-mode"
data-slot="task-chat-mode-trigger"
data-pending-work-mode={pendingMode}
>
{modeMeta.label}
@@ -990,7 +1447,7 @@ export function TaskChatComposer({
noneLabel="No assignee"
searchPlaceholder="Search assignees…"
emptyMessage="No matches."
onChange={setPendingAssignee}
onChange={updatePendingAssignee}
disabled={disabled}
triggerTestId="task-chat-composer-assignee"
className="h-8 gap-1.5 border-0 bg-transparent px-2.5 text-xs shadow-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-0"
@@ -1042,47 +1499,70 @@ export function TaskChatComposer({
<button
type="button"
onClick={() => void submit()}
onClick={() => void (showStop ? stopControl.stop() : submit())}
disabled={
disabled ||
submitting ||
uploadPending ||
uploadFailed ||
(body.trim().length === 0 && attachedRefs.length === 0)
showStop
? disabled || stopControl.stopping
: disabled ||
submitting ||
!!uncertainSubmission ||
uploadPending ||
uploadFailed ||
(body.trim().length === 0 && attachedRefs.length === 0)
}
title={
queuedEdit
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: uploadPending
? "Waiting for upload to finish"
: uploadFailed
? "Remove the failed attachment to send"
: "Send (⌘+Enter)"
showStop
? stopControl.stopping
? "Stopping…"
: stopScope === "subtree"
? "Stop and pause subtree"
: "Stop and pause task"
: queuedEdit
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: uploadPending
? "Waiting for upload to finish"
: uploadFailed
? "Remove the failed attachment to send"
: "Send (⌘+Enter)"
}
aria-label={
queuedEdit
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: "Send"
showStop
? stopControl.stopping
? "Stopping…"
: "Stop"
: queuedEdit
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: "Send"
}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center transition-transform hover:scale-105 disabled:scale-100",
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-transform hover:scale-105 disabled:scale-100",
streamlined
? "rounded-full bg-foreground text-background disabled:bg-foreground disabled:text-background disabled:opacity-100"
: "rounded-md bg-primary text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
? "bg-foreground text-background disabled:bg-foreground disabled:text-background disabled:opacity-100"
: "bg-primary text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
)}
data-testid="task-chat-composer-send"
data-testid={
showStop ? "task-chat-composer-stop" : "task-chat-composer-send"
}
data-slot="icon-button"
>
{submitting ? (
{submitting || (showStop && stopControl.stopping) ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : showStop ? (
<Square className="h-4 w-4 fill-current" aria-hidden />
) : (
<ArrowUp className="h-4 w-4" aria-hidden />
)}
</button>
</div>
{stopControl.error ? (
<p role="alert" className="text-xs text-destructive">
{stopControl.error}
</p>
) : null}
</>
)}
</div>
@@ -8,6 +8,8 @@ import { createPortal } from "react-dom";
interface TaskChatComposerTakeoverActions {
skipButton: ReactNode;
/** Hides the takeover without resolving it, returning the plain composer. */
dismiss: () => void;
headerSlot: HTMLElement | null;
controlsSlot: HTMLElement | null;
setHeaderClaimed: (claimed: boolean) => void;
@@ -437,6 +437,7 @@ describe("TaskChatInteractionCard", () => {
button.textContent?.includes("Only collapse hidden descendants"),
);
await act(async () => firstAnswer?.click());
expect(submit).not.toHaveBeenCalled();
expect(container.textContent).toContain("2 of 2");
expect(container.textContent).toContain(
@@ -2,6 +2,9 @@ import type { ComponentProps } from "react";
import type { IssueDocument } from "@paperclipai/shared";
import type { MentionOption } from "@/components/MarkdownEditor";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { AppLogo } from "@/pages/apps/AppLogo";
import { MarkdownBody } from "@/components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions";
import { TaskChatCompactInteractionCard } from "./TaskChatCompactInteractionCard";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
@@ -14,6 +17,7 @@ type InteractionCardProps = Omit<
export interface TaskChatInteractionCardProps extends InteractionCardProps {
item: TaskChatInteractionItem;
onReviewRequest?: (interactionId: string) => void;
planDocument?: IssueDocument | null;
showPlanPreview?: boolean;
presentation?: "timeline" | "takeover";
@@ -30,6 +34,7 @@ export interface TaskChatInteractionCardProps extends InteractionCardProps {
*/
export function TaskChatInteractionCard({
item,
onReviewRequest,
planDocument,
showPlanPreview = true,
presentation = "timeline",
@@ -37,13 +42,32 @@ export function TaskChatInteractionCard({
...cardProps
}: TaskChatInteractionCardProps) {
const interaction = item.interaction;
if (interaction.kind === "request_confirmation" && interaction.payload.toolAction) {
const action = interaction.payload.toolAction;
if (presentation === "takeover") {
return <IssueThreadInteractionCard interaction={interaction} {...cardProps} />;
}
return (
<div id={`interaction-${interaction.id}`} data-testid="task-chat-tool-review" className="w-full space-y-2">
{interaction.status === "pending" ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<AppLogo name={action.appDisplayName || action.toolDisplayName} size={36} />
<div className="min-w-0 flex-1"><MarkdownBody>{action.previewMarkdown.split(/\n\s*\n/)[0] || action.toolDisplayName}</MarkdownBody></div>
<Button className="ml-auto" size="sm" variant="outline" disabled={!onReviewRequest} onClick={() => onReviewRequest?.(interaction.id)}>Review request</Button>
</div>
) : (
<IssueThreadInteractionCard interaction={interaction} {...cardProps} />
)}
</div>
);
}
const isSupersededQuestionReceipt =
interaction.kind === "ask_user_questions" &&
interaction.status !== "pending" &&
interaction.result?.expirationReason === "superseded_by_newer_interaction";
if (shouldHideInteractionCard(interaction) && !isSupersededQuestionReceipt)
return null;
if (presentation === "timeline" && interaction.status === "pending") {
if (presentation === "timeline" && interaction.status === "pending" && interaction.kind !== "connection_intent") {
const isPlanReview =
interaction.kind === "request_confirmation" &&
Boolean(interaction.sourceRunId) &&
@@ -2,7 +2,8 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ExecutionProjection } from "@paperclipai/shared";
import type { TranscriptEntry } from "../../adapters";
import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "./TaskChatLiveRunPill";
@@ -50,6 +51,7 @@ describe("TaskChatLiveRunPill", () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
it("shimmers 'Working' with elapsed + tool summary while streaming", () => {
@@ -71,6 +73,34 @@ describe("TaskChatLiveRunPill", () => {
expect(pill?.textContent).toContain("called 3 tools");
});
it.each(["reconnecting", "retry_scheduled"] as const)(
"keeps Working animated and the timer advancing with a %s projection",
(phase) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-11T12:00:00Z"));
const startedAtMs = Date.now() - 6_000;
const execution = { phase } as ExecutionProjection;
const render = (status: string) => act(() => root.render(
<TaskChatLiveRunPill status={status} execution={execution}
startedAtMs={startedAtMs} finishedAtMs={startedAtMs + 8_000}
toolSummary="called 2 tools" />,
));
render("running");
expect(container.querySelector(".shimmer-text")?.textContent).toBe("Working");
expect(container.querySelector(".animate-spin")).not.toBeNull();
expect(container.textContent).toContain("for 6 seconds");
expect(container.textContent).toContain("called 2 tools");
act(() => vi.advanceTimersByTime(2_000));
expect(container.textContent).toContain("for 8 seconds");
render("succeeded");
expect(container.textContent).toContain("Worked");
expect(container.querySelector(".animate-spin")).toBeNull();
render("failed");
expect(container.textContent).toContain("Stopped");
expect(container.textContent).not.toContain("Reconnecting");
},
);
it("settles to a static 'Worked' summary once the run is terminal", () => {
const startedAtMs = 1_000;
act(() => {
@@ -1,3 +1,4 @@
import type { ExecutionProjection } from "@paperclipai/shared";
import { Loader2 } from "lucide-react";
import type { TranscriptEntry } from "../../adapters";
import { cn } from "@/lib/utils";
@@ -49,6 +50,7 @@ export function TaskChatLiveRunPill({
toolSummary,
}: {
status: string;
execution?: ExecutionProjection | null;
/** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
startedAtMs: number | null;
/** Run finish in ms once terminal; drives the settled elapsed readout. */
@@ -65,7 +67,8 @@ export function TaskChatLiveRunPill({
const elapsed = elapsedMs != null
? formatDurationWords(elapsedMs)
: null;
const verb = active ? "Working" : "Worked";
const failed = ["failed", "timed_out", "cancelled", "interrupted"].includes(status);
const verb = active ? "Working" : failed ? "Stopped" : "Worked";
const suffix = elapsed ? `for ${elapsed}` : null;
return (
@@ -78,7 +81,7 @@ export function TaskChatLiveRunPill({
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500/70" />
<span className={cn("h-1.5 w-1.5 rounded-full", (failed || active) ? "bg-muted-foreground/40" : "bg-emerald-500/70")} />
</span>
)}
{active ? <span className={cn("shimmer-text")}>{verb}</span> : verb}
@@ -72,7 +72,7 @@ describe("TaskChatLiveTail", () => {
expect(container.textContent).not.toContain("const x = 1;");
expect(container.textContent).not.toContain("+1 −1");
const tool = container.querySelector<HTMLButtonElement>(".tc-enter-tool button");
const tool = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-tool-card"] button');
expect(tool).not.toBeNull();
flushSync(() => tool?.click());
@@ -195,7 +195,7 @@ describe("TaskChatLiveTail", () => {
render(items);
const tool = container.querySelector<HTMLButtonElement>(
".tc-enter-tool button",
'[data-testid="task-chat-tool-card"] button',
);
const collapsedTarget = tool?.querySelector(
".task-chat-collapsed-line-fade",
@@ -42,7 +42,8 @@ describe("TaskChatMarker", () => {
kind: "marker",
variant: "interrupted",
label: "Run failed",
detail: "The runner stopped before returning an answer (runner_exited).",
detail:
"The runner stopped before returning an answer (runner_exited).",
collapsible: true,
createdAtIso: "2026-09-01T12:00:00.000Z",
runHref: "/agents/codex/runs/run-1",
@@ -70,10 +71,39 @@ describe("TaskChatMarker", () => {
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("runner_exited");
expect(
container.querySelector('a[href="/agents/codex/runs/run-1"]')?.textContent,
container.querySelector('a[href="/agents/codex/runs/run-1"]')
?.textContent,
).toBe("View run");
});
it("keeps expected cancellation neutral when collapsed and expanded", () => {
flushSync(() =>
root!.render(
<ThemeProvider>
<TaskChatMarker
item={{
id: "cancelled",
kind: "marker",
variant: "interrupted",
tone: "neutral",
label: "Run cancelled",
detail: "Cancelled by you.",
collapsible: true,
}}
/>
</ThemeProvider>,
),
);
const toggle = container.querySelector<HTMLButtonElement>(
"button[aria-expanded]",
)!;
expect(toggle.classList).toContain("text-muted-foreground");
expect(container.querySelector(".text-destructive")).toBeNull();
flushSync(() => toggle.click());
expect(container.textContent).toContain("Cancelled by you.");
expect(container.querySelector(".text-destructive")).toBeNull();
});
it("keeps Try again available without retry instructions in the detail", async () => {
const onTryAgain = vi.fn();
flushSync(() =>
@@ -97,7 +127,9 @@ describe("TaskChatMarker", () => {
const retry = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-run-failed-try-again"]',
)!;
expect(container.textContent).not.toContain("You can retry this message now");
expect(container.textContent).not.toContain(
"You can retry this message now",
);
flushSync(() => retry.click());
await Promise.resolve();
expect(onTryAgain).toHaveBeenCalledTimes(1);
+36 -10
View File
@@ -1,6 +1,6 @@
import { useId, useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, CircleDot, OctagonX, Flag } from "lucide-react";
import { ChevronDown, CircleDot, OctagonX, Square, Flag } from "lucide-react";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import type { TaskChatMarkerItem } from "./task-chat-model";
import { Button } from "@/components/ui/button";
@@ -30,8 +30,8 @@ export function TaskChatMarker({
const [open, setOpen] = useState(false);
const detailsId = useId();
const streamlined = useStreamlinedTaskChatPresentation();
const Icon = VARIANT_ICON[item.variant];
const interrupted = item.variant === "interrupted";
const Icon = item.tone === "neutral" ? Square : VARIANT_ICON[item.variant];
const interrupted = item.variant === "interrupted" && item.tone !== "neutral";
const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
const handleTryAgain = () => {
void Promise.resolve()
@@ -53,14 +53,17 @@ export function TaskChatMarker({
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5 text-destructive",
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5",
interrupted ? "text-destructive" : "text-muted-foreground",
"hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="truncate font-medium">{item.label}</span>
{relative ? (
<span className="shrink-0 text-muted-foreground/70">· {relative}</span>
<span className="shrink-0 text-muted-foreground/70">
· {relative}
</span>
) : null}
<ChevronDown
className={cn(
@@ -90,7 +93,9 @@ export function TaskChatMarker({
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
>
{item.detail ? (
<div className="px-3 py-2.5 text-foreground/90">{item.detail}</div>
<div className="px-3 py-2.5 text-foreground/90">
{item.detail}
</div>
) : null}
{item.runHref || onTryAgain ? (
<div className="flex items-center justify-end gap-2 border-t border-border/70 bg-background/50 px-3 py-2 dark:bg-background/30">
@@ -124,11 +129,25 @@ export function TaskChatMarker({
role={streamlined ? "separator" : undefined}
aria-label={streamlined ? item.label : undefined}
>
<span className={cn("h-px flex-1", interrupted ? "border-t border-dashed border-destructive/50" : "bg-border")} />
<span className={cn("flex items-center gap-1.5", interrupted && "text-destructive")}>
<span
className={cn(
"h-px flex-1",
interrupted
? "border-t border-dashed border-destructive/50"
: "bg-border",
)}
/>
<span
className={cn(
"flex items-center gap-1.5",
interrupted && "text-destructive",
)}
>
<Icon className="h-3.5 w-3.5" />
<span className="font-medium">{item.label}</span>
{item.detail ? <span className="text-muted-foreground">· {item.detail}</span> : null}
{item.detail ? (
<span className="text-muted-foreground">· {item.detail}</span>
) : null}
{onTryAgain ? (
<Button
type="button"
@@ -142,7 +161,14 @@ export function TaskChatMarker({
</Button>
) : null}
</span>
<span className={cn("h-px flex-1", interrupted ? "border-t border-dashed border-destructive/50" : "bg-border")} />
<span
className={cn(
"h-px flex-1",
interrupted
? "border-t border-dashed border-destructive/50"
: "bg-border",
)}
/>
</div>
);
}
@@ -0,0 +1,75 @@
import { useId } from "react";
import { Loader2, Pause, Play } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
export interface TaskComposerPause {
scope: "leaf" | "subtree";
onResume?: () => void;
resumeHref?: string;
pending?: boolean;
error?: string | null;
}
/** Replaces all input controls until the effective pause hold is released. */
export function TaskChatPausedTakeover({
scope,
resumeHref,
hasDraft = false,
pending = false,
error,
onResume,
}: {
hasDraft?: boolean;
} & TaskComposerPause) {
const headingId = useId();
const subtree = scope === "subtree";
return (
<section
aria-labelledby={headingId}
aria-busy={pending}
data-testid="paused-composer-takeover"
className="flex flex-col gap-4 rounded-(--radius-task-composer) border border-(--status-agent-paused)/40 bg-(--status-agent-paused)/10 p-(--sz-18px)"
>
<div className="flex items-start gap-3">
<Pause aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-(--status-task-icon-todo)" />
<div className="flex min-w-0 flex-col gap-1">
<h2 id={headingId} className="text-sm font-medium text-foreground">
{subtree ? "Subtree is paused." : "Task is paused."}
</h2>
<p className="text-sm text-muted-foreground">
{subtree
? "Resume this subtree to send a message."
: "Resume this task to send a message."}
</p>
</div>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<div className="flex flex-wrap items-center justify-end gap-3">
{hasDraft ? (
<p className="mr-auto text-xs text-muted-foreground">Your draft is saved.</p>
) : null}
{resumeHref ? (
<Button asChild size="sm" className="bg-(--status-agent-paused) text-foreground hover:bg-(--status-agent-paused)/80 dark:text-background">
<Link to={resumeHref}><Play aria-hidden="true" />Resume subtree</Link>
</Button>
) : (
<Button
size="sm"
disabled={pending || !onResume}
onClick={onResume}
className="bg-(--status-agent-paused) text-foreground hover:bg-(--status-agent-paused)/80 dark:text-background"
>
{pending ? <Loader2 aria-hidden="true" className="animate-spin" /> : <Play aria-hidden="true" />}
{pending ? "Resuming…" : subtree ? "Resume subtree" : "Resume task"}
</Button>
)}
</div>
</section>
);
}
@@ -164,6 +164,24 @@ describe("TaskChatProtocolActivityRow", () => {
expect(row?.querySelector('[data-testid="task-chat-protocol-activity-icon"]')?.querySelectorAll("path")).toHaveLength(3);
});
it("shows a notice as a warning and full-width message without metadata or a disclosure", () => {
const summary = "Project-local configuration is disabled.\nTrust the repository to load its hooks.";
render({
id: "notice", kind: "protocol", surface: "provider_activity", family: "provider_notice",
eventType: "provider.notice.recorded", status: "informational", title: "Provider notice", summary,
details: [
{ label: "Category", value: "configWarning" },
{ label: "Recoverable", value: "Yes" },
{ label: "Summary", value: summary },
], steps: [], links: [], children: [],
});
expect(container.textContent).toBe(`Warning${summary}`);
expect(container.querySelector("p")?.textContent).toBe(summary);
expect(container.querySelector("dl")).toBeNull();
expect(container.querySelector("button")).toBeNull();
expect(container.querySelector('[data-testid="task-chat-protocol-activity-icon"]')).not.toBeNull();
});
it("does not make an informational row focusable when it has no details", () => {
render({
id: "notice",
@@ -1,5 +1,6 @@
import { useId, useState, type ReactNode } from "react";
import {
AlertTriangle,
Check,
ChevronRight,
Circle,
@@ -252,6 +253,20 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
const detailId = `task-chat-protocol-activity-${useId().replaceAll(":", "")}`;
const presentation = protocolActivityPresentation(item);
if (!presentation) return null;
if (item.surface === "provider_activity" && item.family === "provider_notice") {
const summary = item.summary
?? item.details.find((entry) => entry.label === "Summary")?.value
?? "The provider reported a notice without a message.";
return (
<div className="flex min-w-0 flex-col gap-1.5 py-1 text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family="provider_notice">
<div className="flex items-center gap-2 text-muted-foreground">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
<span className="font-medium">{item.status === "failed" ? "Error" : "Warning"}</span>
</div>
<p className="min-w-0 whitespace-pre-wrap break-words text-foreground">{summary}</p>
</div>
);
}
const detail = detailContent(item);
const expandable = detail !== null;
const Icon = presentation.icon;
@@ -13,6 +13,8 @@ import type {
TaskChatRuntimeRequestDecision,
} from "./task-chat-model";
import type { IssueWorkProduct } from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { RichWorkProductCard } from "./RichWorkProductCard";
import { stateChipFor } from "./RichWorkProductCard";
function workProduct(overrides: Partial<IssueWorkProduct> = {}): IssueWorkProduct {
@@ -179,6 +181,41 @@ describe("TaskChatProtocolCard", () => {
expect(container.textContent).toContain("Open gallery");
});
it.each(["image/png", "video/webm"])("opens %s artifacts in the task gallery", (contentType) => {
const openGallery = vi.fn(() => true);
const contentPath = "/api/attachments/media/content";
flushSync(() => root.render(
<IssueGalleryContext.Provider value={openGallery}>
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
href={contentPath}
variant="compact"
/>
</IssueGalleryContext.Provider>,
));
const button = container.querySelector<HTMLButtonElement>('button[aria-label^="Open gallery:"]');
expect(button).not.toBeNull();
expect(container.querySelector("a")).toBeNull();
flushSync(() => button!.click());
expect(openGallery).toHaveBeenCalledWith(contentPath);
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("opens standalone artifact media in a modal with a download", async () => {
const contentPath = "/api/attachments/media/content";
flushSync(() => root.render(
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", title: "Screenshot", metadata: { contentType: "image/png", contentPath, originalFilename: "proof.png", downloadPath: `${contentPath}?download=1` } })}
href={contentPath}
/>,
));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open gallery: Screenshot"]')!.click());
expect(document.querySelector('[role="dialog"] img')?.getAttribute("src")).toBe(contentPath);
expect(document.querySelector('a[aria-label="Download proof.png"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`);
await act(async () => document.querySelector<HTMLButtonElement>('button[title="Close"]')!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("keeps completed and approved states out of the state-chip policy", () => {
expect(stateChipFor("commit", "completed", "none")).toBeNull();
expect(stateChipFor("document", "approved", "approved")).toBeNull();
@@ -567,14 +604,22 @@ describe("TaskChatProtocolCard", () => {
(button) => button.textContent?.includes("Production"),
);
await act(async () => production?.click());
// Single selection advances; multi-selection waits for Next.
const nextButton = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Next",
);
expect(container.textContent).toContain("2 of 3");
expect(onDecision).not.toHaveBeenCalled();
expect(container.textContent).toContain(
"Which regions should receive the release?",
);
const progress = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === "Continue");
expect(progress).not.toBeUndefined();
await act(async () => progress?.click());
expect(
Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Continue",
),
).toBeUndefined();
await act(async () => nextButton()?.click());
expect(container.textContent).toContain("Anything else we should know?");
const submit = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Continue",
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import type { ExecutionProjection } from "@paperclipai/shared";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -40,6 +41,7 @@ describe("TaskChatRunnerTurn", () => {
) => void,
suppressFinal = false,
continuedAfterSteering = false,
execution?: ExecutionProjection,
) =>
act(() =>
root.render(
@@ -50,6 +52,7 @@ describe("TaskChatRunnerTurn", () => {
agentName="Runner"
items={items}
status={status}
execution={execution}
startedAtMs={Date.now() - 2_000}
suppressFinal={suppressFinal}
continuedAfterSteering={continuedAfterSteering}
@@ -141,6 +144,26 @@ describe("TaskChatRunnerTurn", () => {
);
});
it.each(["reconnecting", "retry_scheduled"] as const)(
"keeps the active turn and Thinking tail visible with a %s projection",
(phase) => {
const execution = { phase } as ExecutionProjection;
render([], "running", "run-1", undefined, false, false, execution);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain("Working for");
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent)
.toBe("Thinking");
render([], "succeeded", "run-1", undefined, false, false, execution);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain("Worked");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
render([], "failed", "run-1", undefined, false, false, execution);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain("Stopped");
expect(container.textContent).not.toContain("Reconnecting");
},
);
it("labels the streaming tail as a continuation after steering", () => {
render([], "running", "run-1", undefined, false, true);
Loaded 100 of 1324 files, more files were not shown because too many files have changed in this diff. Show more