diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index 7472e11ffe5..2271749e335 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -153,6 +153,7 @@ describe("opencode remote execution", () => { config: { command: "opencode", model: "opencode/gpt-5-nano", + extraArgs: ["--", "--dir"], ...(managed ? { managedAiConnection: { provider: "openrouter", method: "api_key" }, } : {}), @@ -236,6 +237,7 @@ describe("opencode remote execution", () => { `${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`, ); expect(modelProbeCall?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(runCall?.[2].slice(-4)).toEqual(["--dir", managedRemoteWorkspace, "--", "--dir"]); const call = runCall as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; @@ -335,7 +337,7 @@ describe("opencode remote execution", () => { expect(startAdapterExecutionTargetPaperclipBridge).not.toHaveBeenCalled(); }); - it("resumes saved OpenCode sessions for remote SSH execution only when the identity matches", async () => { + it("resumes matching remote sessions and preserves an explicit run directory", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); @@ -370,6 +372,7 @@ describe("opencode remote execution", () => { config: { command: "opencode", model: "opencode/gpt-5-nano", + extraArgs: ["--dir", "/operator-supplied"], }, context: { paperclipWorkspace: { @@ -397,5 +400,7 @@ describe("opencode remote execution", () => { | undefined; expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("session-123"); + expect(call?.[2].slice(-2)).toEqual(["--dir", "/operator-supplied"]); + expect(call?.[2].filter((arg) => arg === "--dir" || arg.startsWith("--dir="))).toHaveLength(1); }); }); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index a7458fd3d97..7171067d005 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -621,6 +621,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise arg === "--dir" || arg.startsWith("--dir="))) { + args.push("--dir", effectiveExecutionCwd); + } if (extraArgs.length > 0) args.push(...extraArgs); return args; }; diff --git a/packages/adapters/pi-local/src/server/models.test.ts b/packages/adapters/pi-local/src/server/models.test.ts index df777544a17..881c86defc1 100644 --- a/packages/adapters/pi-local/src/server/models.test.ts +++ b/packages/adapters/pi-local/src/server/models.test.ts @@ -1,3 +1,6 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ensurePiModelConfiguredAndAvailable, @@ -30,4 +33,34 @@ describe("pi models", () => { }), ).rejects.toThrow(); }); + + it("does not embed the model catalogue when the configured model is unavailable", async () => { + const directory = await mkdtemp(join(tmpdir(), "paperclip-pi-models-")); + const command = join(directory, "pi"); + const modelIds = Array.from({ length: 13 }, (_, index) => `provider/model-${index}`); + await writeFile( + command, + `#!/bin/sh\nprintf 'provider model\\n';\n${modelIds + .map((id) => { + const [provider, model] = id.split("/"); + return `printf '${provider} ${model}\\n'`; + }) + .join(";\n")}\n`, + ); + await chmod(command, 0o755); + + try { + await expect( + ensurePiModelConfiguredAndAvailable({ + command, + model: "provider/missing", + }), + ).rejects.toMatchObject({ + message: + "Configured Pi model is unavailable: provider/missing. 13 models are available; list them with `pi --list-models`.", + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/packages/adapters/pi-local/src/server/models.ts b/packages/adapters/pi-local/src/server/models.ts index a6081cad738..06b1a803474 100644 --- a/packages/adapters/pi-local/src/server/models.ts +++ b/packages/adapters/pi-local/src/server/models.ts @@ -188,9 +188,8 @@ export async function ensurePiModelConfiguredAndAvailable(input: { } if (!models.some((entry) => entry.id === model)) { - const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); throw new Error( - `Configured Pi model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, + `Configured Pi model is unavailable: ${model}. ${models.length} models are available; list them with \`pi --list-models\`.`, ); } diff --git a/ui/src/components/EmailMessageCard.tsx b/ui/src/components/EmailMessageCard.tsx index be4a083e15f..b1c374e1ac4 100644 --- a/ui/src/components/EmailMessageCard.tsx +++ b/ui/src/components/EmailMessageCard.tsx @@ -42,7 +42,9 @@ export function EmailMessageCard({ message, publication, issueId, + contextNotice = false, }: { + contextNotice?: boolean; message: EmailMessage; publication?: EmailPublicationSummary; issueId: string; @@ -57,7 +59,7 @@ export function EmailMessageCard({ aria-label={ message.direction === "inbound" ? "Email received" : "Email sent" } - className="space-y-4 rounded-xl border border-border bg-card p-5" + className={contextNotice ? "task-context-notice space-y-3" : "space-y-4 rounded-xl border border-border bg-card p-5"} >
diff --git a/ui/src/components/EmailTaskActivity.tsx b/ui/src/components/EmailTaskActivity.tsx index 6f442b1372c..6a384fb1d0e 100644 --- a/ui/src/components/EmailTaskActivity.tsx +++ b/ui/src/components/EmailTaskActivity.tsx @@ -36,6 +36,7 @@ export function EmailTaskActivity({ )} {messages.map((m) => (

{p.request.subject ?? "Email reply"}

{p.request.to &&

To: {p.request.to.join(", ")}

} diff --git a/ui/src/components/ExecutionBlockerNotice.test.tsx b/ui/src/components/ExecutionBlockerNotice.test.tsx index 3960f71070b..980f0a6fdf1 100644 --- a/ui/src/components/ExecutionBlockerNotice.test.tsx +++ b/ui/src/components/ExecutionBlockerNotice.test.tsx @@ -36,8 +36,7 @@ describe("stopped task recovery notice", () => { const notice = container.querySelector('[role="status"][aria-label="Task recovery"]')!; expect(notice.textContent).toContain("Recorded work is preserved"); expect(notice.textContent).toContain("Retry"); - expect(notice.classList.contains("border")).toBe(true); - expect(notice.classList.contains("bg-muted")).toBe(true); + expect(notice.classList.contains("task-context-notice")).toBe(true); expect(notice.querySelector("a")?.getAttribute("href")).toBe("/agents/agent/runs/failed-run"); }); it.each([false, true])("explains cancelled runs and saved input; continue eligibility %s", async canContinue => { diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index c157a359b5d..0cb1eb0b775 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -4,9 +4,11 @@ import { agentsApi } from "../api/agents"; import { activityApi } from "../api/activity"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "./ui/button"; +import { cn } from "../lib/utils"; import { Link } from "../lib/router"; -export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried }: { +export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried, className }: { + className?: string; companyId: string; issueId: string; blocker: ExecutionBlocker; @@ -33,7 +35,7 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried }, }); return ( -
+

{modelRejected ? "Model unavailable." : "Recovery needed."}{blocker.runError ? ` ${blocker.runError}` : ""}

{modelRejected &&

Choose a supported model or clear the task's model override, then retry.

} diff --git a/ui/src/components/WorkspaceExportRecovery.tsx b/ui/src/components/WorkspaceExportRecovery.tsx index 23ae74319b1..98376816817 100644 --- a/ui/src/components/WorkspaceExportRecovery.tsx +++ b/ui/src/components/WorkspaceExportRecovery.tsx @@ -3,12 +3,14 @@ import { useMutation } from "@tanstack/react-query"; import type { IssueRecoveryAction } from "@paperclipai/shared"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; +import { cn } from "../lib/utils"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Textarea } from "./ui/textarea"; /** Copyback repair preserves the accepted result and never wakes the provider. */ -export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued }: { +export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued, className }: { + className?: string; issueId: string; action: IssueRecoveryAction | null; canManage: boolean; onQueued: () => void; }) { const noteId = useId(); @@ -23,7 +25,7 @@ export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued } if (!action || !isNativeWorkspaceExportRepairCause(action.cause) || action.ownerType !== "board" || !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return null; const queued = queuedActionVersion === String(action.updatedAt) || action.wakePolicy?.kind === "resume_native_run"; - return
+ return

Workspace export needs repair

{queued ?

Export is queued for the saved result. The agent will not repeat its work.

: <>

{"Automatic workspace export retries stopped. Inspect the export failure, restore provider or destination availability, and preserve the saved files in the retained sandbox. Retry export here when the cause is resolved."}

diff --git a/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx b/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx index 221f28c14aa..bb92647d243 100644 --- a/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx +++ b/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx @@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/context/ToastContext"; import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; import { queryKeys } from "@/lib/queryKeys"; import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled"; import { issuesApi } from "@/api/issues"; @@ -124,6 +125,7 @@ export function useIssueChatBinding(companyId: string, issueId: string) { } type ConnectedTaskProps = { + className?: string; attachments?: IssueAttachment[]; companyId: string; issueId: string; @@ -148,6 +150,7 @@ export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) { } function ConnectedTaskComposer({ + className, attachments = [], companyId, issueId, @@ -486,7 +489,7 @@ function ConnectedTaskComposer({ return (
diff --git a/ui/src/index.css b/ui/src/index.css index c45d030ffc3..ce91a3a3dba 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -3120,11 +3120,13 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { inset-inline: 0; } -/* Task recovery panel uses the shared spacing, radius, and type scales. */ -.execution-blocker-notice { - gap: calc(var(--spacing) * 2); - padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3); - border-radius: var(--radius-md); +/* Connection and recovery cards share one surface and spacing scale. */ +.task-context-notice { + padding: calc(var(--spacing) * 3); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background-color: var(--muted); + color: var(--foreground); font-size: var(--text-sm); line-height: var(--text-sm--line-height); } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a7b7ff66094..b7ebb693a9a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -7642,6 +7642,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS {taskChatShellEnabled ? null : createdFromNote} + {resolvedDetailTab === "chat" && ( + + )} + {resolvedDetailTab === "chat" && issue.executionBlocker && ( + + )} + {issue.status === "in_review" && issue.externalConversationState === "waiting" && (

Reply sent. Send a message to continue.

)} @@ -7952,11 +7962,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS : undefined } > - - {issue.executionBlocker && ( - - )} {resolvedDetailTab === "chat" ? ( { invalidateIssueDetail(); invalidateIssueCollections(); }} unavailableReason={!canManageBoardRuntime || !canResolveBoardRecoveryAction ? "You don’t have permission to repair this task’s workspace." diff --git a/ui/storybook/stories/task-context-notices.stories.tsx b/ui/storybook/stories/task-context-notices.stories.tsx new file mode 100644 index 00000000000..f92b8ffaaac --- /dev/null +++ b/ui/storybook/stories/task-context-notices.stories.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { expect, within } from "storybook/test"; +import { ExternallyConnectedTaskBanner } from "@/components/chat/ExternallyConnectedTaskBanner"; +import { ExecutionBlockerNotice } from "@/components/ExecutionBlockerNotice"; +import { EmailMessageCard } from "@/components/EmailMessageCard"; +import { queryKeys } from "@/lib/queryKeys"; + +const shellSectionClass = "mx-auto w-full max-w-(--tc-shell-max-w)"; + +function TaskContextNotices() { + const [client] = useState(() => { + const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + cache.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: true }); + cache.setQueryData(["issue-chat-binding", "demo", "context-notices"], { + endpointId: "slack-demo", provider: "slack", externalLabel: "#design · Card styling", + externalUrl: "https://slack.com", conversationId: "demo-thread", assignedAgentLocked: true, + }); + cache.setQueryData(queryKeys.issues.runs("context-notices"), []); + return cache; + }); + return ( + +
+
+

Chat connection and recovery

+ + {}} + blocker={{ recoveryActionId: "demo-recovery", runId: null, agentId: null, cause: "action_outcome_unknown", + nextAction: "Inspect the run before continuing. Recorded work is preserved." }} /> +
+ +
+
+
+
+ ); +} + +const meta = { + title: "Tasks/Context notices", + component: TaskContextNotices, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const ConnectionAndRecovery: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const connection = await canvas.findByRole("region", { name: "External conversation" }); + const recovery = canvas.getByRole("status", { name: "Task recovery" }); + const email = canvas.getByRole("article", { name: "Email received" }); + for (const card of [recovery, email]) { + await expect(card.getBoundingClientRect().width).toBe(connection.getBoundingClientRect().width); + await expect(getComputedStyle(card).backgroundColor).toBe(getComputedStyle(connection).backgroundColor); + await expect(getComputedStyle(card).padding).toBe(getComputedStyle(connection).padding); + await expect(getComputedStyle(card).borderRadius).toBe(getComputedStyle(connection).borderRadius); + } + }, +};