mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
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:
commit
cd008e3444
1324 files changed
+1488084
-52191
No files matched your search
@@ -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={() => {}} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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
+2195
-876
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 }>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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">×</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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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">›</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">×</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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: `` }} />
|
||||
</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 {
|
||||
|
||||
@@ -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
@@ -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 `` 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(
|
||||
``,
|
||||
);
|
||||
} 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);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user