Merge master into qualified test lifecycle fixes

Preserve master retention I/O settlement and held-I/O coverage alongside the controlled fairness clock and exact run completion barriers. Retain the delegated-completion fixture without changing production code.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-31 07:57:47 -05:00
commit ab0b15d234
465 files changed
+33126 -1543

No files matched your search

@@ -64,6 +64,7 @@ export interface AdapterDisplayInfo {
const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
antigravity_runtime: { label: "Google Antigravity", description: "Antigravity through Paperclip Runner", icon: Gem, hideFromVisualSelection: true },
copilot_runtime: { label: "GitHub Copilot", description: "Copilot through Paperclip Runner", icon: Code, hideFromVisualSelection: true },
kimi_runtime: { label: "Kimi Code (Runner)", description: "Kimi Code through Paperclip Runner", icon: Moon, hideFromVisualSelection: true },
acpx_local: {
label: "ACPX (retired)",
description: "Retired standalone ACPX adapter",
@@ -310,7 +310,7 @@ export function CodexLocalConfigFields({
checked={runnerSchemaValue("allowUnmeteredProvider", false) === true} onChange={value => updateRunnerSchemaValue("allowUnmeteredProvider", value)} />
</>}
{runnerManaged && !hideRunnerHarness && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") !== "grok" && (
<Field configSection="adapter" label="ACP agent" hint="Cursor uses Paperclip questions; per-run cost is unavailable. Pi accepts any provider/model ID. GitHub Copilot awaits qualification.">
<Field configSection="adapter" label="ACP agent" hint="Choose a qualified harness and enter its exact model ID.">
<Select
value={String(isCreate ? values!.adapterSchemaValues?.acpxAgent ?? "claude" : eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude"))}
onValueChange={(value) => {
+2 -2
View File
@@ -377,8 +377,8 @@ export const issuesApi = {
api.put<IssueWatchdog>(`/issues/${id}/watchdog`, data),
deleteWatchdog: (id: string) =>
api.delete<{ ok: true }>(`/issues/${id}/watchdog`),
markRead: (id: string) =>
api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
markRead: (id: string, throughCommentId?: string) =>
api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, throughCommentId ? { throughCommentId } : {}),
markUnread: (id: string) =>
api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`),
archiveFromInbox: (id: string) =>
+4
View File
@@ -11,6 +11,10 @@ const brandMarks: Record<string, { src: string; dark?: string }> = {
src: "/brands/adapters/kimi-color-light.svg",
dark: "/brands/adapters/kimi-color.svg",
},
kimi_runtime: {
src: "/brands/adapters/kimi-color-light.svg",
dark: "/brands/adapters/kimi-color.svg",
},
...Object.fromEntries(
[
["cursor", "cursor"],
@@ -1311,6 +1311,16 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
});
it("requests the native Kimi model catalog while preserving an explicit saved model", async () => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
const result = await renderForm([], {
adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "kimi", model: "explicit-kimi-model" },
});
roots.push(result.root);
await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("company-1", "paperclip_runner", expect.objectContaining({ provider: "acpx", acpxAgent: "kimi" })));
expect(result.container.textContent).toContain("explicit-kimi-model");
});
it.each(["api_key", "subscription"] as const)("loads saved Antigravity %s models through its selected connection without a Claude fallback", async method => {
const binding = { provider: "google", method, mode: "responsible_user" } as const;
const accounts = vi.spyOn(aiConnectionsApi, "list").mockResolvedValue({ currentUserId: "you", canManageConnections: true, connections: [{
+2 -2
View File
@@ -1017,7 +1017,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? String(isCreate ? props.values.adapterSchemaValues?.acpxAgent ?? "claude" : eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude")) : undefined;
const modelAiBinding = aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data;
const accountBoundCatalog = isAccountBoundAcpxAgent(runnerAcpxAgent);
const modelCacheIdentity = accountBoundCatalog ? `${modelProvider}:${runnerAcpxAgent}:${isCreate ? "new" : props.agent.id}:${JSON.stringify(modelAiBinding ?? null)}` : modelProvider;
const modelCacheIdentity = accountBoundCatalog ? `${modelProvider}:${runnerAcpxAgent}:${isCreate ? "new" : props.agent.id}:${JSON.stringify(modelAiBinding ?? null)}` : runnerAcpxAgent ? `${modelProvider}:${runnerAcpxAgent}` : modelProvider;
const connectionModels = useConnectionModels(isExternalRunner ? null : selectedCompanyId, isCreate ? undefined : modelAiBinding, aiRoutingHarness(adapterType, runnerProvider, runnerAcpxAgent));
// Fetch adapter models for the effective provider, including unsaved changes.
const modelAdapterType = accountBoundCatalog ? "paperclip_runner" : isRunnerHarness ? harnessType : adapterType;
@@ -1411,7 +1411,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
setRefreshingModels(true);
setRefreshModelsError(null);
try {
const refreshed = await agentsApi.adapterModels(selectedCompanyId, modelAdapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider, ...(accountBoundCatalog ? { acpxAgent: runnerAcpxAgent, aiConnection: modelAiBinding, agentId: !isCreate ? props.agent.id : undefined } : {}) });
const refreshed = await agentsApi.adapterModels(selectedCompanyId, modelAdapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider, ...(runnerAcpxAgent ? { acpxAgent: runnerAcpxAgent } : {}), ...(accountBoundCatalog ? { aiConnection: modelAiBinding, agentId: !isCreate ? props.agent.id : undefined } : {}) });
queryClient.setQueryData(modelQueryKey, refreshed);
} catch (error) {
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
@@ -15,6 +15,7 @@ export interface AgentConversationSidebarProps {
availableAgents?: Agent[];
activeId?: string;
previews?: Record<string, string>;
unreadAgentIds?: readonly string[];
loading?: boolean;
error?: Error | null;
onRetry?: () => void;
@@ -28,7 +29,7 @@ export interface AgentConversationSidebarProps {
}
/** Searchable navigation for one conversation per agent. */
export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) {
export function AgentConversationSidebar({ agents, existingChatAgentIds, availableAgents = agents, activeId, previews = {}, unreadAgentIds = [], loading = false, error, onRetry, initialSearch = "", onSelect, onBrowse, onAddChat, historyLoading, historyError, onRetryHistory }: AgentConversationSidebarProps) {
const navigate = useNavigate();
const [pickerOpen, setPickerOpen] = useState(false);
const [search, setSearch] = useState(initialSearch);
@@ -82,6 +83,7 @@ export function AgentConversationSidebar({ agents, existingChatAgentIds, availab
<span className="truncate text-sm font-medium">{agent.name}</span>
<span className="truncate text-xs text-muted-foreground">{agent.status === "terminated" ? "Terminated" : agent.status === "paused" ? "Paused" : previews[agent.id] ?? agent.title ?? "Start a conversation"}</span>
</span>
{unreadAgentIds.includes(agent.id) && <span role="img" aria-label={`Unread messages from ${agent.name}`} className="size-2 shrink-0 rounded-full bg-primary" />}
</Link>)}
</nav> : !agents.length && (historyLoading || historyError) ? null : <div className="flex flex-col items-start gap-2 px-2 py-6">
<p className="text-sm font-medium">{agents.length ? "No agents found" : "No chats yet"}</p>
@@ -90,6 +90,19 @@ it("preserves recent activity order when reopening an older conversation", async
expect(links()).toEqual(["/chats/bob", "/chats/alice"]);
});
it("shows accessible unread indicators only for the current company and human", async () => {
client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [{ ...chat("alice"), isUnreadForMe: true }, chat("bob")]);
client.setQueryData(queryKeys.agentChats.list("company-a", "other-human"), [{ ...chat("bob"), isUnreadForMe: true }]);
await render();
expect(container.querySelector('[aria-label="Unread messages from alice"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Unread messages from bob"]')).toBeNull();
state.companyId = "company-b";
client.setQueryData(queryKeys.agents.list("company-b"), []);
client.setQueryData(queryKeys.agentChats.list("company-b", "user-a"), []);
await render();
expect(container.querySelector('[aria-label^="Unread messages"]')).toBeNull();
});
it("retains terminated agents' history by id without offering them for new chats", async () => {
const retired = { ...roster[0], id: "retired-id", name: "Retired", status: "terminated", urlKey: "alice" };
client.setQueryData(queryKeys.agents.detail(retired.id), retired);
@@ -47,6 +47,7 @@ export function AgentConversationsSidebar() {
return <AgentConversationSidebar key={`${companyId}:${userId}`} agents={[...conversations, ...teammates]} availableAgents={roster}
existingChatAgentIds={conversations.map(agent => agent.id)}
activeId={active?.id} previews={previews}
unreadAgentIds={(chats.data ?? []).filter(chat => chat.isUnreadForMe).flatMap(chat => chat.conversationAgentId ? [chat.conversationAgentId] : [])}
// Loaded chats stay listed through an outage (the banner covers it); an
// outage before the first load is a skeleton, and only a real failure is an error.
loading={[agents, chats, session].some(query => { const kind = queryViewKind(query); return kind === "loading" || kind === "reconnecting"; })}
+11 -2
View File
@@ -10,9 +10,9 @@ import { coordinatorAttentionQueryKey, coordinatorAttentionSummaryQueryKey } fro
import { queryKeys } from "@/lib/queryKeys";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer" }));
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }> }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: [] }, session: { isSuccess: true },
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: fixture.chats }, session: { isSuccess: true },
}) }));
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: [] }) }));
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) }));
@@ -43,12 +43,21 @@ function summary(items: AttentionItem[]) {
let root: Root, container: HTMLDivElement, client: QueryClient;
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer"; fixture.pathname = "/COO/issues";
fixture.chats = [];
fixture.summary.mockResolvedValue(summary([pending("question"), pending("review")]));
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
describe("Coordinator pinned pages", () => {
it("announces unread chat totals in expanded and collapsed navigation", async () => {
fixture.chats = [{ isUnreadForMe: true }, { isUnreadForMe: false }, { isUnreadForMe: true }];
await renderSidebar();
expect(container.querySelector('a[href="/chats"] [aria-label="2 unread chats"]')).not.toBeNull();
fixture.collapsed = true;
await renderSidebar();
expect(container.querySelector('a[href="/chats"]')?.getAttribute("aria-label")).toBe("Chat, 2 unread chats");
});
const pinnedPages = () => container.querySelector('[aria-label="Pinned pages"]');
async function click(label: string) {
const button = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
+2 -1
View File
@@ -62,6 +62,7 @@ function ProjectRecent({ project }: { project: Project }) {
* or task just because someone visits or switches agents. */
export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
const { companyId, userId, agents, chats, session } = useAgentChatNavigation();
const unreadChatCount = chats.data?.filter(chat => chat.isUnreadForMe).length ?? 0;
const pinnedNavigation = usePinnedNavigation(userId);
const [moreOpen, setMoreOpen] = useState(false);
const moreContentRef = useRef<HTMLDivElement>(null);
@@ -99,7 +100,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
<div className="design-sidebar-brand"><SidebarCompanyMenu showChevron={false} /></div>
<nav className="design-sidebar-navigation" aria-label="Workspace navigation">
<div className="design-sidebar-primary">
<SidebarNavItem className="design-sidebar-item" to="/chats" label="Chat" icon={MessageCircle} />
<SidebarNavItem className="design-sidebar-item" to="/chats" label="Chat" icon={MessageCircle} badge={unreadChatCount} badgeLabel="unread chats" />
<div className="group/coordinator-new relative">
<SidebarNavItem to="/issues" label="Tasks" icon={CircleCheck} className={cn("design-sidebar-item", !rail && "pr-9")} />
{!rail && <button type="button" aria-label="New task" title="New task" onClick={() => openNewIssue()}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act as reactAct } from "react";
import { act as reactAct, useState } from "react";
import type { ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
@@ -314,6 +314,417 @@ describe("IssueDocumentsSection", () => {
container.remove();
});
it.each([false, true])("restores an unfinished document after its Artifacts pane unmounts (general only: %s)", async generalDocumentsOnly => {
mockIssuesApi.listDocuments.mockResolvedValue([]);
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
function TaskPane() {
const [artifacts, setArtifacts] = useState(true);
return <>
<button onClick={() => setArtifacts(true)}>Artifacts</button>
<button onClick={() => setArtifacts(false)}>Properties</button>
{artifacts ? <IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : null}
</>;
}
await act(async () => root.render(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
await flush();
const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!;
await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
await act(async () => markdownEditorMockState.changeBody?.("Body typed before its key"));
const title = container.querySelector<HTMLInputElement>('input[placeholder="Optional title"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(title, "Unfinished note");
title.dispatchEvent(new Event("input", { bubbles: true }));
});
const form = container.querySelector<HTMLInputElement>('input[placeholder="Document key"]')!.parentElement!;
await act(async () => form.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: button("Properties") })));
expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
await act(async () => button("Properties").click());
expect(container.textContent).not.toContain("Body typed before its key");
await act(async () => button("Artifacts").click());
await flush();
expect(container.textContent).toContain("Body typed before its key");
expect(container.querySelector<HTMLInputElement>('input[placeholder="Document key"]')?.value).toBe("");
expect(container.querySelector<HTMLInputElement>('input[placeholder="Optional title"]')?.value).toBe("Unfinished note");
expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
const cancel = Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("Cancel"))!;
await act(async () => cancel.click());
await act(async () => button("Properties").click());
await act(async () => button("Artifacts").click());
await flush();
expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
expect(container.textContent).not.toContain("Body typed before its key");
await act(async () => root.unmount());
client.clear();
});
it("keeps new document drafts within their task and signed-in account", async () => {
mockIssuesApi.listDocuments.mockResolvedValue([]);
const issue = createIssue();
const otherTask = { ...issue, id: "task-b", identifier: "DOC-2" };
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const show = (task: Issue) => root.render(<QueryClientProvider client={client}><IssueDocumentsSection issue={task} canDeleteDocuments={false} generalDocumentsOnly /></QueryClientProvider>);
await act(async () => show(issue));
await flush();
await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
await act(async () => markdownEditorMockState.changeBody?.("Private unfinished document"));
await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }));
await flush();
expect(container.textContent).not.toContain("Private unfinished document");
expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
await act(async () => {
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
show(otherTask);
});
await flush();
expect(container.textContent).not.toContain("Private unfinished document");
await act(async () => show(issue));
await flush();
expect(container.textContent).toContain("Private unfinished document");
expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
await act(async () => root.unmount());
client.clear();
});
it.each([
{ generalDocumentsOnly: false, saveOutcome: "saved" },
{ generalDocumentsOnly: false, saveOutcome: "failed" },
{ generalDocumentsOnly: true, saveOutcome: "saved" },
{ generalDocumentsOnly: true, saveOutcome: "failed" },
])("reopens the latest edits after pending creation ($generalDocumentsOnly, $saveOutcome)", async ({ generalDocumentsOnly, saveOutcome }) => {
const { ApiError } = await import("../api/client");
const issue = createIssue();
const initial = createIssueDocument({ key: "notes", title: "Initial title", body: "Initial body", latestRevisionId: "created-revision", latestRevisionNumber: 1 });
const revised = { ...initial, title: "Latest title", body: "Latest body", latestRevisionId: "edited-revision", latestRevisionNumber: 2 };
let savedDocuments: IssueDocument[] = [];
const pendingCreate = deferred<IssueDocument>();
mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments);
mockIssuesApi.upsertDocument.mockImplementationOnce(async () => {
const saved = await pendingCreate.promise;
savedDocuments = [saved];
return saved;
}).mockImplementationOnce(async () => {
if (saveOutcome === "failed") throw new ApiError("Save refused", 400, null);
savedDocuments = [revised];
return revised;
});
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
function TaskPane() {
const [artifacts, setArtifacts] = useState(true);
return <>
<button onClick={() => setArtifacts(true)}>Artifacts</button>
<button onClick={() => setArtifacts(false)}>Properties</button>
{artifacts ? <IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : null}
</>;
}
const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!;
const typeInput = async (placeholder: string, value: string) => act(async () => {
const input = container.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
try {
await act(async () => root.render(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
await flush();
await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
await typeInput("Document key", "notes");
await typeInput("Optional title", initial.title!);
await act(async () => markdownEditorMockState.changeBody?.(initial.body));
await act(async () => button("Create document").click());
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce();
await typeInput("Optional title", "Title typed during creation");
await act(async () => markdownEditorMockState.changeBody?.("Body typed during creation"));
await act(async () => pendingCreate.resolve(initial));
await flush();
const titleAfterCreate = container.querySelector<HTMLInputElement>('input[placeholder="Optional title"]')?.value;
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Body typed during creation");
await typeInput("Optional title", revised.title);
await act(async () => markdownEditorMockState.changeBody?.(revised.body));
await act(async () => new Promise(resolve => setTimeout(resolve, 1100)));
await flush();
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledTimes(2);
expect(mockIssuesApi.upsertDocument).toHaveBeenLastCalledWith(issue.id, "notes", expect.objectContaining({
title: revised.title, body: revised.body, baseRevisionId: initial.latestRevisionId,
}));
await act(async () => button("Properties").click());
await act(async () => button("Artifacts").click());
await flush();
expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
expect(container.textContent).toContain(revised.body);
expect(titleAfterCreate).toBe("Title typed during creation");
expect(localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:${generalDocumentsOnly ? "general" : "all"}`)).toBeNull();
const editBackup = localStorage.getItem(`paperclip:document-draft:v2:user-1:${issue.id}:notes`);
if (saveOutcome === "failed") {
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe(revised.body);
expect(container.querySelector<HTMLInputElement>('input[placeholder="Optional title"]')?.value).toBe(revised.title);
expect(JSON.parse(editBackup!)).toMatchObject({ body: revised.body, title: revised.title, baseRevisionId: initial.latestRevisionId, isNew: false });
} else expect(editBackup).toBeNull();
} finally {
await act(async () => root.unmount());
client.clear();
}
});
it.each([
{ generalDocumentsOnly: false, reopenBeforeCreate: false, remoteChange: false },
{ generalDocumentsOnly: false, reopenBeforeCreate: true, remoteChange: false },
{ generalDocumentsOnly: true, reopenBeforeCreate: false, remoteChange: false },
{ generalDocumentsOnly: true, reopenBeforeCreate: true, remoteChange: false },
{ generalDocumentsOnly: true, reopenBeforeCreate: false, remoteChange: true },
])("resumes edits when creation finishes after the Artifacts pane unmounts ($generalDocumentsOnly, $reopenBeforeCreate, $remoteChange)", async ({ generalDocumentsOnly, reopenBeforeCreate, remoteChange }) => {
const { ApiError } = await import("../api/client");
const issue = createIssue();
const initial = createIssueDocument({ key: "notes", title: "Submitted title", body: "Submitted body", latestRevisionId: "created-revision", latestRevisionNumber: 1 });
const remote = { ...initial, title: "Remote title", body: "Another writer's text", latestRevisionId: "remote-revision", latestRevisionNumber: 2 };
let savedDocuments: IssueDocument[] = [];
const pendingCreate = deferred<IssueDocument>();
mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments);
mockIssuesApi.getDocument.mockImplementation(async () => savedDocuments[0]);
mockIssuesApi.upsertDocument.mockImplementation(async (_issueId, _key, data) => {
if (mockIssuesApi.upsertDocument.mock.calls.length === 1) {
const saved = await pendingCreate.promise;
savedDocuments = [saved];
return saved;
}
if (data.baseRevisionId !== savedDocuments[0].latestRevisionId) throw new ApiError("Document changed", 409, null);
const revised = { ...initial, title: data.title, body: data.body, latestRevisionId: "edited-revision", latestRevisionNumber: 2 };
savedDocuments = [revised];
return revised;
});
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
function TaskPane() {
const [artifacts, setArtifacts] = useState(true);
return <>
<button onClick={() => setArtifacts(true)}>Artifacts</button>
<button onClick={() => setArtifacts(false)}>Properties</button>
{artifacts ? <IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /> : null}
</>;
}
const button = (name: string) => Array.from(container.querySelectorAll("button")).find(element => element.textContent === name)!;
const typeInput = async (placeholder: string, value: string) => act(async () => {
const input = container.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const title = reopenBeforeCreate ? "Newest reopened title" : "Title typed during creation";
const body = reopenBeforeCreate ? "Newest reopened body" : "Body typed during creation";
try {
await act(async () => root.render(<QueryClientProvider client={client}><TaskPane /></QueryClientProvider>));
await flush();
await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
await typeInput("Document key", "notes");
await typeInput("Optional title", initial.title!);
await act(async () => markdownEditorMockState.changeBody?.(initial.body));
await act(async () => button("Create document").click());
await typeInput("Optional title", "Title typed during creation");
await act(async () => markdownEditorMockState.changeBody?.("Body typed during creation"));
await act(async () => button("Properties").click());
expect(container.querySelector('[data-testid="markdown-editor"]')).toBeNull();
if (reopenBeforeCreate) {
await act(async () => button("Artifacts").click());
await flush();
await typeInput("Optional title", title);
await act(async () => markdownEditorMockState.changeBody?.(body));
}
await act(async () => pendingCreate.resolve(initial));
await flush();
if (remoteChange) await act(async () => {
savedDocuments = [remote];
client.setQueryData(queryKeys.issues.documents(issue.id), savedDocuments);
});
if (!reopenBeforeCreate) await act(async () => button("Artifacts").click());
await flush();
expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe(body);
expect(container.querySelector<HTMLInputElement>('input[placeholder="Optional title"]')?.value).toBe(title);
await act(async () => new Promise(resolve => setTimeout(resolve, 1100)));
await flush();
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledTimes(2);
expect(mockIssuesApi.upsertDocument).toHaveBeenLastCalledWith(issue.id, "notes", expect.objectContaining({ title, body, baseRevisionId: initial.latestRevisionId }));
if (remoteChange) {
expect(savedDocuments).toEqual([remote]);
expect(container.textContent).toContain("Out of date");
expect(container.textContent).toContain("Overwrite remote");
} else {
expect(container.textContent).not.toContain("Out of date");
expect(savedDocuments[0]).toMatchObject({ title, body, latestRevisionId: "edited-revision" });
await act(async () => button("Properties").click());
await act(async () => button("Artifacts").click());
await flush();
expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
expect(container.textContent).toContain(body);
}
} finally {
await act(async () => root.unmount());
client.clear();
}
});
it.each([false, true])("keeps a newer new-document form when an earlier create completes (general only: %s)", async generalDocumentsOnly => {
const issue = createIssue();
const saved = createIssueDocument({ key: "first", title: null, body: "Submitted first body" });
let savedDocuments: IssueDocument[] = [];
const pendingCreate = deferred<IssueDocument>();
mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments);
mockIssuesApi.upsertDocument.mockImplementation(async () => {
const saved = await pendingCreate.promise;
savedDocuments = [saved];
return saved;
});
let root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const show = () => root.render(<QueryClientProvider client={client}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly={generalDocumentsOnly} /></QueryClientProvider>);
const typeKey = async (value: string) => act(async () => {
const input = container.querySelector<HTMLInputElement>('input[placeholder="Document key"]')!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
try {
await act(async () => show());
await flush();
await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
await typeKey("first");
await act(async () => markdownEditorMockState.changeBody?.(saved.body));
await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent === "Create document")!.click());
await typeKey("second");
await act(async () => markdownEditorMockState.changeBody?.("Keep the newer document body"));
await act(async () => pendingCreate.resolve(saved));
await flush();
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => show());
await flush();
expect(container.querySelector<HTMLInputElement>('input[placeholder="Document key"]')?.value).toBe("second");
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep the newer document body");
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce();
} finally {
await act(async () => root.unmount());
client.clear();
}
});
it("keeps the new-document backup if storing the pending-create edits fails", async () => {
const issue = createIssue();
const saved = createIssueDocument({ key: "notes", title: null, body: "Submitted body" });
let savedDocuments: IssueDocument[] = [];
const pendingCreate = deferred<IssueDocument>();
mockIssuesApi.listDocuments.mockImplementation(async () => savedDocuments);
mockIssuesApi.upsertDocument.mockImplementation(async () => {
const saved = await pendingCreate.promise;
savedDocuments = [saved];
return saved;
});
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
const write = localStorage.setItem.bind(localStorage);
const methodOwner: Storage = Object.hasOwn(localStorage, "setItem") ? localStorage : Object.getPrototypeOf(localStorage);
const editKey = `paperclip:document-draft:v2:user-1:${issue.id}:notes`;
const setItem = vi.spyOn(methodOwner, "setItem").mockImplementation((key, value) => {
if (key === editKey) throw new Error("Storage full");
write(key, value);
});
try {
await act(async () => root.render(<QueryClientProvider client={client}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} generalDocumentsOnly /></QueryClientProvider>));
await flush();
await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent?.includes("New document"))!.click());
await act(async () => {
const input = container.querySelector<HTMLInputElement>('input[placeholder="Document key"]')!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "notes");
input.dispatchEvent(new Event("input", { bubbles: true }));
markdownEditorMockState.changeBody?.(saved.body);
});
await act(async () => Array.from(container.querySelectorAll("button")).find(element => element.textContent === "Create document")!.click());
await act(async () => markdownEditorMockState.changeBody?.("Keep the unsaved pending-create edits"));
await act(async () => pendingCreate.resolve(saved));
await flush();
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep the unsaved pending-create edits");
expect(localStorage.getItem(editKey)).toBeNull();
const retained = localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:general`);
expect(JSON.parse(retained!)).toMatchObject({ key: "notes", body: "Keep the unsaved pending-create edits", baseRevisionId: saved.latestRevisionId, isNew: false });
await act(async () => container.querySelector('[data-testid="markdown-editor"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
expect(localStorage.getItem(`paperclip:new-document-draft:v1:user-1:${issue.id}:general`)).toBeNull();
} finally {
setItem.mockRestore();
await act(async () => root.unmount());
client.clear();
}
});
it("keeps native Plans out of the general-document authoring surface", async () => {
mockIssuesApi.listDocuments.mockResolvedValue([
createIssueDocument({ body: "Native Plan content" }),
createIssueDocument({ id: "note", key: "notes", title: "Task note", body: "General document content" }),
createIssueDocument({ id: "file-review", key: "artifact-review-output", body: "Work product review content" }),
]);
markdownEditorMockState.emitMountValue = "A new general note";
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><IssueDocumentsSection
issue={{ ...createIssue(), legacyPlanDocument: { key: "plan", source: "issue_description", body: "Legacy Plan content" } }}
canDeleteDocuments={false} generalDocumentsOnly
/></QueryClientProvider>));
await flush();
expect(container.textContent).toContain("General document content");
expect(container.textContent).not.toContain("Native Plan content");
expect(container.textContent).not.toContain("Legacy Plan content");
expect(container.textContent).not.toContain("Work product review content");
const newDocument = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("New document"))!;
await act(async () => newDocument.click());
const keyInput = container.querySelector<HTMLInputElement>('input[placeholder="Document key"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(keyInput, "plan");
keyInput.dispatchEvent(new Event("input", { bubbles: true }));
});
const create = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create document"))!;
await act(async () => create.click());
await flush();
expect(container.textContent).toContain("Create and revise Plans in the task conversation.");
expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
await act(async () => root.unmount());
client.clear();
});
it.each([
["continuation-summary", "This document key is reserved for task state."],
["pipeline-case-body", "This document key is reserved for task state."],
["artifact-review-output", "Review this document through its work product."],
])("refuses reserved general-document key %s and preserves the draft", async (key, message) => {
mockIssuesApi.listDocuments.mockResolvedValue([]);
mockIssuesApi.upsertDocument.mockResolvedValue(createIssueDocument({ key, body: "Keep my document draft" }));
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><IssueDocumentsSection
issue={createIssue()} canDeleteDocuments={false} generalDocumentsOnly
/></QueryClientProvider>));
await flush();
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("New document"))!.click());
const keyInput = container.querySelector<HTMLInputElement>('input[placeholder="Document key"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(keyInput, key);
keyInput.dispatchEvent(new Event("input", { bubbles: true }));
markdownEditorMockState.changeBody?.("Keep my document draft");
});
expect(container.textContent).toContain(message);
const create = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create document"))!;
await act(async () => create.click());
await flush();
expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
expect(container.textContent).toContain(message);
expect(keyInput.value).toBe(key);
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("Keep my document draft");
await act(async () => root.unmount());
client.clear();
});
it("unfolds a Classic confirmation link and loads its bound revision rather than the latest text", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -1181,6 +1592,13 @@ describe("IssueDocumentsSection", () => {
await act(async () => {
root.unmount();
});
const reopened = createRoot(container);
await act(async () => reopened.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={issue} canDeleteDocuments={false} /></QueryClientProvider>));
await flush();
expect(container.querySelector('input[placeholder="Document key"]')).toBeNull();
expect(container.textContent).toContain("Notes body");
expect(mockIssuesApi.upsertDocument).toHaveBeenCalledOnce();
await act(async () => reopened.unmount());
queryClient.clear();
});
+104 -17
View File
@@ -10,7 +10,7 @@ import type {
Issue,
IssueDocument,
} from "@paperclipai/shared";
import { isSystemIssueDocumentKey } from "@paperclipai/shared";
import { isArtifactReviewDocumentKey, isSystemIssueDocumentKey } from "@paperclipai/shared";
import { useLocation } from "@/lib/router";
import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
@@ -22,6 +22,7 @@ import { deriveDocumentRevisionState } from "../lib/document-revisions";
import { buildDocumentAnnotationHash, parseDocumentAnnotationHash } from "../lib/document-annotation-hash";
import type { CompanyUserProfile } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import { isGeneralTaskDocumentKey } from "../lib/issue-artifacts";
import { useDraftOwner } from "../hooks/useDraftOwner";
import { cn, relativeTime } from "../lib/utils";
import { FoldCurtain } from "./FoldCurtain";
@@ -134,6 +135,13 @@ function isPlanKey(key: string) {
return key.trim().toLowerCase() === "plan";
}
function generalDocumentKeyError(key: string): string | null {
if (isGeneralTaskDocumentKey(key)) return null;
if (isPlanKey(key)) return "Create and revise Plans in the task conversation.";
if (isArtifactReviewDocumentKey(key)) return "Review this document through its work product.";
return "This document key is reserved for task state.";
}
function titlesMatchKey(title: string | null | undefined, key: string) {
return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase();
}
@@ -178,6 +186,28 @@ function clearStoredDocumentDraft(scope: string | null, key: string) {
}
}
function newDocumentDraftStorageKey(scope: string) {
return `paperclip:new-document-draft:v1:${scope}`;
}
function loadStoredNewDocumentDraft(scope: string | null): DraftState | null {
if (!scope) return null;
const stored = loadStructuredDraft<Partial<DraftState> | null>(newDocumentDraftStorageKey(scope), null);
// Creation may finish after this pane unmounts. Its retained newer buffer
// then carries the confirmed revision and reopens as an edit.
if (!stored || typeof stored.isNew !== "boolean" || typeof stored.key !== "string" || typeof stored.title !== "string"
|| typeof stored.body !== "string" || (stored.isNew ? stored.baseRevisionId !== null : typeof stored.baseRevisionId !== "string")) return null;
return { key: stored.key, title: stored.title, body: stored.body, baseRevisionId: stored.baseRevisionId ?? null, isNew: stored.isNew };
}
function clearStoredNewDocumentDraft(scope: string | null, saved?: DraftState) {
if (!scope) return;
const stored = loadStoredNewDocumentDraft(scope);
// A save may finish after more text was typed or after the pane unmounted.
if (saved && stored && (stored.key !== saved.key || stored.title !== saved.title || stored.body !== saved.body)) return;
try { localStorage.removeItem(newDocumentDraftStorageKey(scope)); } catch { /* Storage may be unavailable. */ }
}
function downloadDocumentFile(key: string, body: string) {
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
@@ -291,6 +321,7 @@ function AccountIssueDocumentsSection({
subject,
canDeleteDocuments,
canManageDocumentLocks = false,
generalDocumentsOnly = false,
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
@@ -309,6 +340,8 @@ function AccountIssueDocumentsSection({
subject?: DocumentSubjectConfig;
canDeleteDocuments: boolean;
canManageDocumentLocks?: boolean;
/** The chat shell keeps Plans in the conversation and file reviews on their work products. */
generalDocumentsOnly?: boolean;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
@@ -354,6 +387,7 @@ function AccountIssueDocumentsSection({
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
const draftOwner = useDraftOwner();
const draftScope = draftOwner !== undefined ? `${draftOwner}:${documentSubject.id}` : null;
const newDraftScope = draftScope ? `${draftScope}:${generalDocumentsOnly ? "general" : "all"}` : null;
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(documentSubject.id));
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
() => (defaultAnnotationPanelOpenKeys ?? []),
@@ -493,12 +527,15 @@ function AccountIssueDocumentsSection({
});
const sortedDocuments = useMemo(() => {
return (documents ?? []).filter((doc) => !documentSubject.hideSystemDocuments || !isSystemIssueDocumentKey(doc.key)).sort((a, b) => {
return (documents ?? []).filter((doc) =>
(!generalDocumentsOnly || isGeneralTaskDocumentKey(doc.key))
&& (!documentSubject.hideSystemDocuments || !isSystemIssueDocumentKey(doc.key)),
).sort((a, b) => {
if (a.key === "plan" && b.key !== "plan") return -1;
if (a.key !== "plan" && b.key === "plan") return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}, [documentSubject.hideSystemDocuments, documents]);
}, [documentSubject.hideSystemDocuments, documents, generalDocumentsOnly]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
@@ -510,11 +547,13 @@ function AccountIssueDocumentsSection({
}, [feedbackVotes]);
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
const isEmpty = sortedDocuments.length === 0 && !documentSubject.legacyPlanDocument;
const newDocumentKeyError =
draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
const legacyPlanDocument = generalDocumentsOnly ? null : documentSubject.legacyPlanDocument;
const isEmpty = sortedDocuments.length === 0 && !legacyPlanDocument;
const newGeneralDocumentKeyError = draft?.isNew && generalDocumentsOnly ? generalDocumentKeyError(draft.key) : null;
const newDocumentKeyError = newGeneralDocumentKeyError
?? (draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
? "Use lowercase letters, numbers, -, or _, and start with a letter or number."
: null;
: null);
const resetAutosaveState = useCallback(() => {
setAutosaveDocumentKey(null);
@@ -529,7 +568,7 @@ function AccountIssueDocumentsSection({
const beginNewDocument = () => {
resetAutosaveState();
setDocumentConflict(null);
setDraft({
setDraft(loadStoredNewDocumentDraft(newDraftScope) ?? {
key: "",
title: "",
body: "",
@@ -576,7 +615,11 @@ function AccountIssueDocumentsSection({
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
if (draft && !draft.isNew) clearStoredDocumentDraft(draftScope, draft.key);
if (draft && !draft.isNew) {
clearStoredDocumentDraft(draftScope, draft.key);
clearStoredNewDocumentDraft(newDraftScope, draft);
}
if (draft?.isNew) clearStoredNewDocumentDraft(newDraftScope);
resetAutosaveState();
setDocumentConflict(null);
setDraft(null);
@@ -593,6 +636,12 @@ function AccountIssueDocumentsSection({
const normalizedTitle = currentDraft.title.trim();
const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null;
const generalKeyError = generalDocumentsOnly ? generalDocumentKeyError(normalizedKey) : null;
if (generalKeyError) {
setError(generalKeyError);
return false;
}
if (activeConflict && !options?.overrideConflict) {
if (options?.trackAutosave) {
resetAutosaveState();
@@ -647,6 +696,18 @@ function AccountIssueDocumentsSection({
};
const applySaved = (saved: IssueDocument) => {
clearStoredDocumentDraft(draftScope, normalizedKey);
clearStoredNewDocumentDraft(newDraftScope, currentDraft);
const retained = currentDraft.isNew ? loadStoredNewDocumentDraft(newDraftScope) : null;
if (newDraftScope && retained?.isNew && retained.key.trim().toLowerCase() === normalizedKey && saved.latestRevisionId) {
// Keep later typing, but bind it to our successful creation even if
// its editor has already unmounted. Other forms/scopes stay untouched.
saveStructuredDraft(newDocumentDraftStorageKey(newDraftScope), {
...retained,
key: saved.key,
baseRevisionId: saved.latestRevisionId,
isNew: false,
});
}
setError(null);
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
setDraft((value) => {
@@ -654,8 +715,8 @@ function AccountIssueDocumentsSection({
if (options?.clearAfterSave) return null;
return {
key: saved.key,
title: saved.title ?? "",
// Keep text typed while this save was in flight.
// Keep title and body edits made while this save was in flight.
title: value.title === currentDraft.title ? saved.title ?? "" : value.title,
body: value.body === currentDraft.body ? saved.body : value.body,
baseRevisionId: saved.latestRevisionId,
isNew: false,
@@ -725,7 +786,7 @@ function AccountIssueDocumentsSection({
// Kept on this device; it saves when the connection allows.
if (result.kind === "waiting") return false;
return handleSaveFailure(result.error);
}, [documentConflict, documentSubject, draftScope, invalidateIssueDocuments, resetAutosaveState, runAutosave, sortedDocuments, syncDocumentCaches, upsertDocument]);
}, [documentConflict, documentSubject, draftScope, generalDocumentsOnly, invalidateIssueDocuments, newDraftScope, resetAutosaveState, runAutosave, sortedDocuments, syncDocumentCaches, upsertDocument]);
const reloadDocumentFromServer = useCallback((key: string) => {
if (documentConflict?.key !== key) return;
@@ -937,15 +998,36 @@ function AccountIssueDocumentsSection({
// Persist every unsaved edit so a reload or an outage cannot lose it.
useEffect(() => {
if (!draft || draft.isNew) return;
if (!draft) return;
if (draft.isNew) {
if (newDraftScope) {
const retained = loadStoredNewDocumentDraft(newDraftScope);
if (retained && !retained.isNew && retained.key === draft.key.trim().toLowerCase()) {
// A previous mount's create can finish after this editor reopened.
// Promote its current text without replacing anything typed since.
setDraft({ ...draft, key: retained.key, baseRevisionId: retained.baseRevisionId, isNew: false });
return;
}
if (draft.key || draft.title || draft.body) saveStructuredDraft(newDocumentDraftStorageKey(newDraftScope), draft);
else clearStoredNewDocumentDraft(newDraftScope);
}
return;
}
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
if (!existing) return;
// Retire a matching new-form backup only after its text is saved remotely
// or retained as an edit. A different newer buffer keeps its backup.
if (existing.body === draft.body && (existing.title ?? "") === draft.title) {
clearStoredDocumentDraft(draftScope, draft.key);
clearStoredNewDocumentDraft(newDraftScope, draft);
} else if (draftScope) {
saveStructuredDraft(documentDraftStorageKey(draftScope, draft.key), draft);
const stored = loadStoredDocumentDraft(draftScope, draft.key);
if (stored?.body === draft.body && stored.title === draft.title && stored.baseRevisionId === draft.baseRevisionId) {
clearStoredNewDocumentDraft(newDraftScope, draft);
}
}
}, [draftScope, draft, sortedDocuments]);
}, [draftScope, draft, newDraftScope, sortedDocuments]);
// After a reload, reopen the first document with an unsaved edit; the
// autosave below then saves it.
@@ -955,6 +1037,11 @@ function AccountIssueDocumentsSection({
if (restoredStoredDraftRef.current || !documents || !draftScope) return;
restoredStoredDraftRef.current = true;
if (draft) return;
const newDocument = loadStoredNewDocumentDraft(newDraftScope);
if (newDocument) {
setDraft(newDocument);
return;
}
for (const doc of sortedDocuments) {
const stored = loadStoredDocumentDraft(draftScope, doc.key);
if (!stored) continue;
@@ -966,7 +1053,7 @@ function AccountIssueDocumentsSection({
return;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documents, draftScope]);
}, [documents, draftScope, newDraftScope]);
useEffect(() => {
if (!draft || draft.isNew) return;
@@ -1108,7 +1195,7 @@ function AccountIssueDocumentsSection({
</div>
)}
{!hasRealPlan && documentSubject.legacyPlanDocument ? (
{!hasRealPlan && legacyPlanDocument ? (
<div
id="document-plan"
className={cn(
@@ -1123,7 +1210,7 @@ function AccountIssueDocumentsSection({
</Badge>
</div>
<div className={documentBodyPaddingClassName}>
{renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
{renderFoldableBody(legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
</div>
</div>
) : null}
+5 -2
View File
@@ -33,6 +33,7 @@ import { SidebarAgents } from "./SidebarAgents";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
import { SidebarRecentTasks } from "./SidebarRecentTasks";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
@@ -60,6 +61,8 @@ export function Sidebar({ children }: { children?: ReactNode }) {
function ClassicSidebar({ children }: { children?: ReactNode }) {
const { openNewIssue } = useDialogActions();
const { enabled: agentChatEnabled } = useAgentChatEnabled();
const chatNavigation = useAgentChatNavigation();
const unreadChatCount = chatNavigation.chats.data?.filter(chat => chat.isUnreadForMe).length ?? 0;
// Every labeled section is collapsible (session-scoped, default open) —
// one policy across static nav groups and the data-driven sections.
const [workOpen, setWorkOpen] = useState(true);
@@ -192,7 +195,7 @@ function ClassicSidebar({ children }: { children?: ReactNode }) {
alert={inboxBadge.failedRuns > 0}
/>
) : null}
{agentChatEnabled && !chatRail ? <SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} /> : null}
{agentChatEnabled && !chatRail ? <SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} badge={unreadChatCount} badgeLabel="unread chats" /> : null}
{showDecisions ? (
<SidebarNavItem
to="/decisions"
@@ -215,7 +218,7 @@ function ClassicSidebar({ children }: { children?: ReactNode }) {
agents you talk to live in the Chat surface's own secondary rail
(ChatContextualSidebar), not in the primary nav. */}
{chatRail ? (
<SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} />
<SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} badge={unreadChatCount} badgeLabel="unread chats" />
) : null}
{/* Combined Inbox + Task List: Inbox is a view inside Tasks, so the
unread/failed-run badge rides on Tasks. */}
+1
View File
@@ -198,6 +198,7 @@ export function SidebarNavItem({
)}
{!rail && hasBadge && (
<Badge variant="ghost"
aria-label={badgeLabel ? `${badge} ${badgeLabel}` : undefined}
className={cn(
"ml-auto px-1.5 leading-none",
badgeTone === "danger"
@@ -3,7 +3,7 @@ import { act, forwardRef, useImperativeHandle, type ForwardedRef } 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 { ConversationTaskHandoff, IssueDocument, IssueThreadInteraction } from "@paperclipai/shared";
import type { ConversationTaskHandoff, Issue, IssueDocument, IssueThreadInteraction } from "@paperclipai/shared";
import { TaskChatThread } from "./TaskChatThread";
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
import { ThemeProvider } from "@/context/ThemeContext";
@@ -77,6 +77,10 @@ const handoff = (id = "task", sourceRunId: string | null = "source-run", second
id: `chat_handoff:${id}`, issueId: id, identifier: `PAPER-${id}`, title: `Created ${id}`, status: "todo",
createdAt: at(second), sourceRunId, suggestionInteractionId: null,
});
const createdTask = (overrides: Partial<Issue> = {}): Issue => ({
id: "child", companyId: "company", parentId: "chat", identifier: "PAPER-102", title: "Verify the repair",
status: "todo", createdByAgentId: "agent", createdAt: new Date(at(5)), ...overrides,
} as Issue);
const suggestion = (status: "pending" | "accepted"): IssueThreadInteraction => ({
...pendingSuggestedTasksInteraction, id: "suggestion", companyId: "company", issueId: "chat", sourceRunId: "source-run",
status, createdAt: new Date(at(3)), updatedAt: new Date(at(3)),
@@ -111,6 +115,46 @@ const anchor = (id: string) => host.querySelector(`[data-thread-anchor="${id}"]`
const before = (earlier: Element, later: Element) => expect(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
describe("Coordinator task creation ordering", () => {
it("shows agent-created subtasks in ordinary task feeds at creation time and refreshes their status", async () => {
state.coordinator = false;
await paint({ conversationMode: false, companyId: "company", createdTasks: [createdTask(), createdTask()] });
const card = host.querySelector<HTMLAnchorElement>('[data-testid="task-chat-created-task"]')!;
expect(card.textContent).toContain("Subtask created");
expect(card.textContent).toContain("Verify the repair");
expect(card.textContent).toContain("Todo");
expect(card.getAttribute("href")).toBe("/issues/PAPER-102");
before(card, anchor("final"));
before(anchor("final"), anchor("next"));
expect(host.querySelectorAll('[data-testid="task-chat-created-task"]')).toHaveLength(1);
await paint({ conversationMode: false, companyId: "company", createdTasks: [createdTask({ status: "done" })] });
expect(host.querySelector('[data-testid="task-chat-created-task"]')).toBe(card);
expect(card.textContent).toContain("Done");
});
it("filters human and foreign-company tasks and links standalone follow-ups by ID", async () => {
state.coordinator = false;
await paint({ conversationMode: false, companyId: "company", createdTasks: [
createdTask({ id: "human", createdByAgentId: null }),
createdTask({ id: "foreign", companyId: "other" }),
createdTask({ id: "chat" }),
createdTask({ parentId: null, identifier: null }),
] });
const cards = host.querySelectorAll<HTMLAnchorElement>('[data-testid="task-chat-created-task"]');
expect(cards).toHaveLength(1);
expect(cards[0].textContent).toContain("Task created");
expect(cards[0].getAttribute("href")).toBe("/issues/child");
});
it("does not duplicate tasks already shown by chat handoffs or accepted suggestions", async () => {
state.handoffs = [handoff("direct")];
await paint({ companyId: "company", interactions: [suggestion("accepted")], createdTasks: [
createdTask({ id: "direct" }), createdTask({ id: "suggested-task" }), createdTask(),
] });
expect(host.querySelectorAll('[data-testid="task-chat-created-task"]')).toHaveLength(1);
expect(anchor("chat_handoff:direct")).toBeTruthy();
expect(anchor("interaction:suggestion").querySelector(".coordinator-created-task")).toBeTruthy();
});
it("keeps the saved plan before its resulting document even when both belong to one native run", async () => {
state.plan = { id: "plan", issueId: "chat", companyId: "company", key: "plan", title: "Plan", body: "Draft the concepts.",
format: "markdown", latestRevisionId: "plan-rev", latestRevisionNumber: 1,
+15 -1
View File
@@ -10,9 +10,10 @@ import {
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
import { workspaceRestoreMarkerDetail } from "@/lib/workspace-restore-marker";
import { credentialAccessNotice } from "@/lib/credential-access-notice";
import type { ActivityEvent, IssueQueuedCommentQueue, TaskBrowser } from "@paperclipai/shared";
import type { ActivityEvent, Issue, IssueQueuedCommentQueue, TaskBrowser } from "@paperclipai/shared";
import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems";
import { skillCreatedItems } from "@/components/task-chat/skill-created-items";
import { taskCreatedItems } from "@/components/task-chat/task-created-items";
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
import { CoordinatorChatHandoffStatus, CoordinatorChatTaskHandoff, standaloneChatHandoffs, useCoordinatorChatTaskHandoffs, useLiveChatHandoffIds } from "@/components/coordinator/CoordinatorChatTaskHandoffs";
import { CoordinatorChatReceiptStatus, useCoordinatorChatDecisionReceipts } from "@/components/coordinator/CoordinatorChatDecisionReceipts";
@@ -429,6 +430,7 @@ function resolvedWithoutUserFacingResponse(value: unknown): boolean {
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread> & {
conversationMode?: boolean;
creationActivity?: ActivityEvent[];
createdTasks?: Issue[];
compactHistory?: boolean;
scrollingHistory?: ScrollingHistory;
historyControl?: ReactNode;
@@ -636,6 +638,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
const liveHandoffIds = useLiveChatHandoffIds(`${currentUserId}:${issueId}`, taskHandoffs.handoffs, taskHandoffs.isSuccess);
const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId);
const createdSkillItems = useMemo(() => skillCreatedItems(props.creationActivity ?? []), [props.creationActivity]);
const createdTaskItems = useMemo(() => {
const representedTaskIds = new Set(taskHandoffs.handoffs.map((handoff) => handoff.issueId));
for (const interaction of interactions ?? []) {
if (interaction.kind !== "suggest_tasks" || interaction.status !== "accepted" || isSuppressedThreadInteraction(interaction)) continue;
for (const task of interaction.result?.createdTasks ?? []) representedTaskIds.add(task.issueId);
}
return taskCreatedItems(props.createdTasks ?? [], issueId, companyId, representedTaskIds);
}, [props.createdTasks, issueId, companyId, taskHandoffs.handoffs, interactions]);
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
string | null
>(null);
@@ -1420,6 +1430,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
for (const item of createdSkillItems) {
entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 2 });
}
for (const item of createdTaskItems) {
entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 3 });
}
// Older sessions arrive with their surrounding messages when history is
// paged in; otherwise they would collect above the loaded conversation.
const historyStart = hasOlderComments
@@ -1447,6 +1460,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}, [
createdProjectItems,
createdSkillItems,
createdTaskItems,
browsers,
hasOlderComments,
comments,
@@ -757,16 +757,18 @@ export function IssueProperties({
const assigneeOverrideChrome = assigneeAdapterType === "claude_local"
&& assigneeOverrideAdapterConfig.chrome === true;
const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(assigneePrimaryAdapterConfig).provider ?? "codex") : undefined;
const catalogAcpxAgent = catalogProvider === "acpx" ? String(assigneePrimaryAdapterConfig.acpxAgent ?? "claude") : undefined;
const catalogEnvironmentId = nativeCatalog ? assignee?.defaultEnvironmentId ?? null : null;
const nativeCatalogScope = JSON.stringify(nativeCatalog ?? null);
const requestNativeModels = Boolean(nativeCatalog && assigneeOptionsOpen && showAssigneeAdapterOptions && supportsAssigneeOverrides);
const { data: assigneeAdapterModels, refetch: refreshAssigneeModels } = useQuery({
queryKey:
companyId && assigneeAdapterType
? [...queryKeys.agents.adapterModels(companyId, assigneeAdapterType, catalogEnvironmentId, catalogProvider), nativeCatalog ?? null]
? [...queryKeys.agents.adapterModels(companyId, assigneeAdapterType, catalogEnvironmentId, catalogProvider), catalogAcpxAgent ?? null, nativeCatalog ?? null]
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, {
provider: catalogProvider,
...(catalogAcpxAgent ? { acpxAgent: catalogAcpxAgent } : {}),
...(nativeCatalog ? { environmentId: catalogEnvironmentId, ...nativeCatalog } : {}),
}),
...(nativeCatalog ? ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
@@ -1,5 +1,5 @@
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { ArtifactUploadContent, type ArtifactUploadEntry } from "@/components/artifacts/ArtifactUploadControls";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
@@ -17,6 +17,8 @@ import {
import { Download, ExternalLink, FileText } from "lucide-react";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { authApi } from "@/api/auth";
import { IssueDocumentsSection } from "@/components/IssueDocumentsSection";
import { activityApi } from "@/api/activity";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
@@ -24,6 +26,7 @@ import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { useIssueWorkProducts } from "@/hooks/useIssueWorkProducts";
import {
documentDisplayTitle,
isGeneralTaskDocumentKey,
selectArtifactAttachments,
workProductHref,
} from "@/lib/issue-artifacts";
@@ -47,8 +50,28 @@ import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
import { ArtifactFileRow, ArtifactRow, ROW_ACTION_CLASS, fileRowSourceFromAttachment, fileRowSourceFromWorkProduct, subjectFromAttachment, subjectFromIssueDocument, subjectFromWorkProduct, workProductStateBadge, type ArtifactSubject } from "@/components/artifacts/v1";
import { artifactUrl } from "@/lib/artifact-card-data";
export type TaskDocumentEditorOptions = Pick<ComponentProps<typeof IssueDocumentsSection>,
"mentions" | "imageUploadHandler" | "externalReferences" | "feedbackVotes" | "feedbackDataSharingPreference"
| "feedbackTermsUrl" | "onVote" | "agentMap" | "userProfileMap">;
/** Shared authoring and signed-in permissions for task and conversation hosts. */
export function TaskGeneralDocuments({ issue, documentEditor }: { issue: Issue; documentEditor: TaskDocumentEditorOptions }) {
const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() });
return (
<IssueDocumentsSection
issue={issue}
generalDocumentsOnly
canDeleteDocuments={Boolean(session?.user?.id)}
canManageDocumentLocks={Boolean(session?.user?.id)}
{...documentEditor}
/>
);
}
interface IssuePropertiesArtifactsTabProps {
issue: Issue;
/** Present only when this host owns the chat shell's document editor. */
documentEditor?: TaskDocumentEditorOptions;
uploadState?: { entries: ArtifactUploadEntry[]; retry: (id: string) => void; dismiss: (id: string) => void };
onOpenDocument?: (document: IssueDocument) => void;
documentDeepLink?: {
@@ -407,11 +430,13 @@ function DocumentRow({
*/
export function IssuePropertiesArtifactsTab({
issue,
documentEditor,
documentDeepLink,
onOpenDocument,
uploadState,
}: IssuePropertiesArtifactsTabProps) {
const { enabled: v1 } = useArtifactsV1Enabled();
const documentAuthoring = documentEditor !== undefined;
const {
data: attachments,
isPending: attachmentsPending,
@@ -446,7 +471,7 @@ export function IssuePropertiesArtifactsTab({
// Proxy review documents (`artifact-review-*`) present only through their
// originating Work product row, never as standalone Documents rows.
const documentRows = (documents ?? []).filter(
(doc) => !isArtifactReviewDocumentKey(doc.key),
(doc) => !isArtifactReviewDocumentKey(doc.key) && (!documentAuthoring || doc.key === "plan"),
);
const reviewDocsByKey = new Map(
(documents ?? []).map((doc) => [doc.key, doc]),
@@ -558,9 +583,20 @@ export function IssuePropertiesArtifactsTab({
</p>
) : null;
const withUploads = (content: ReactNode, empty = false) => uploadState ? (
<ArtifactUploadContent empty={empty} entries={uploadState.entries} onRetry={uploadState.retry} onDismiss={uploadState.dismiss}>{content}</ArtifactUploadContent>
) : content;
const withUploads = (content: ReactNode, empty = false) => {
const artifacts = uploadState ? (
<ArtifactUploadContent empty={empty} entries={uploadState.entries} onRetry={uploadState.retry} onDismiss={uploadState.dismiss}>{content}</ArtifactUploadContent>
) : content;
return documentAuthoring ? (
<div className="space-y-4">
<TaskGeneralDocuments
issue={issue}
documentEditor={{ agentMap: agentsById, ...documentEditor }}
/>
{artifacts}
</div>
) : artifacts;
};
if (
workProductRows.length === 0 &&
@@ -568,6 +604,9 @@ export function IssuePropertiesArtifactsTab({
fileRows.length === 0
) {
if (feedback) return withUploads(feedback);
if (documentAuthoring && (documents ?? []).some((doc) => isGeneralTaskDocumentKey(doc.key))) {
return withUploads(null);
}
return withUploads(
<div className="px-1 py-6 text-sm text-muted-foreground">
No artifacts yet. Work products, documents, and agent-produced files
@@ -6,7 +6,7 @@ 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 { Issue, IssueAttachment, IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab";
import { IssuePropertiesArtifactsTab, TaskGeneralDocuments, type TaskDocumentEditorOptions } from "./IssuePropertiesArtifactsTab";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -16,11 +16,21 @@ const mockIssuesApi = vi.hoisted(() => ({
ensureWorkProductReviewDocument: vi.fn(async (): Promise<unknown> => ({})),
}));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: "user-1" } }) } }));
let editorOptions: TaskDocumentEditorOptions | undefined;
const documentEditor = vi.hoisted(() => vi.fn());
vi.mock("@/components/IssueDocumentsSection", () => ({
IssueDocumentsSection: (props: { issue: Issue; generalDocumentsOnly?: boolean }) => {
documentEditor(props);
return <section data-testid="general-document-editor"><button>New document</button></section>;
},
}));
vi.mock("@/api/activity", () => ({ activityApi: { runsForIssue: vi.fn(async (): Promise<unknown[]> => []) } }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(async (): Promise<unknown[]> => []) } }));
const mockUseIssueDocuments = vi.hoisted(() => vi.fn((): { data: IssueDocument[] } => ({ data: [] })));
vi.mock("@/hooks/useIssueDocuments", () => ({ useIssueDocuments: mockUseIssueDocuments }));
vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ useArtifactsV1Enabled: () => ({ enabled: true, loaded: true }) }));
const artifactsV1 = vi.hoisted(() => ({ enabled: true, loaded: true }));
vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ useArtifactsV1Enabled: () => artifactsV1 }));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => <a href={to} {...props}>{children}</a>,
useLocation: () => ({ hash: "" }),
@@ -157,6 +167,8 @@ describe("IssuePropertiesArtifactsTab with Artifacts V1 on", () => {
mockIssuesApi.listAttachments.mockResolvedValue([heroAttachment]);
mockIssuesApi.listWorkProducts.mockResolvedValue([pdfWorkProduct]);
mockUseIssueDocuments.mockReturnValue({ data: [planDocument] });
editorOptions = undefined;
artifactsV1.enabled = true;
});
afterEach(() => {
@@ -165,17 +177,90 @@ describe("IssuePropertiesArtifactsTab with Artifacts V1 on", () => {
vi.clearAllMocks();
});
function render(props: { onOpenDocument?: (document: IssueDocument) => void } = {}) {
function render(props: { onOpenDocument?: (document: IssueDocument) => void; documentEditor?: TaskDocumentEditorOptions } = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssuePropertiesArtifactsTab issue={issue} {...props} />
<IssuePropertiesArtifactsTab issue={issue} documentEditor={editorOptions} {...props} />
</QueryClientProvider>,
);
});
}
it("restores the shared general document editor in the default chat Artifacts pane without duplicate cards", async () => {
editorOptions = {};
const note = { ...planDocument, id: "doc-note", key: "notes", title: "Task note", body: "Saved note" };
mockUseIssueDocuments.mockReturnValue({ data: [planDocument, note] });
render();
await waitFor(() => {
expect(container.querySelectorAll('[data-testid="artifact-v1-row"]')).toHaveLength(3);
expect(container.querySelectorAll('[data-testid="general-document-editor"]')).toHaveLength(1);
});
expect(documentEditor).toHaveBeenCalledWith(expect.objectContaining({ issue, generalDocumentsOnly: true }));
expect(container.querySelector('[aria-label="Open Task note"]')).toBeNull();
expect(container.querySelector('[aria-label="Open plan.md"]')).not.toBeNull();
expect(container.querySelector('a[aria-label="Download acmehats-interface-craft.pdf"]')).not.toBeNull();
expect(container.querySelector('img[src="/api/attachments/' + IMAGE_ID + '/content"]')).not.toBeNull();
});
it("leaves Classic authoring in the task body when this host does not own the editor", async () => {
render();
await waitFor(() => expect(container.querySelector('[aria-label="Open plan.md"]')).not.toBeNull());
expect(container.querySelector('[data-testid="general-document-editor"]')).toBeNull();
expect(documentEditor).not.toHaveBeenCalled();
});
it("makes document creation available on an empty default-chat task", async () => {
editorOptions = {};
mockUseIssueDocuments.mockReturnValue({ data: [] });
mockIssuesApi.listAttachments.mockResolvedValue([]);
mockIssuesApi.listWorkProducts.mockResolvedValue([]);
render();
await waitFor(() => expect(container.textContent).toContain("No artifacts yet"));
expect(container.querySelectorAll('[data-testid="general-document-editor"]')).toHaveLength(1);
});
it("forwards image upload, mentions, references and feedback from the owning task shell", async () => {
const options: TaskDocumentEditorOptions = {
imageUploadHandler: vi.fn(async () => "/api/attachments/image/content"),
mentions: [{ id: "agent", name: "Author", kind: "agent" }],
externalReferences: {}, feedbackVotes: [], feedbackDataSharingPreference: "prompt",
feedbackTermsUrl: "https://example.test/terms", onVote: vi.fn(async () => {}), userProfileMap: new Map(),
};
render({ documentEditor: options });
await waitFor(() => expect(documentEditor).toHaveBeenCalledWith(expect.objectContaining(options)));
});
it("uses the same conversation-scoped editor and signed-in permissions beside agent artifacts", async () => {
const conversation = { ...issue, conversationAgentId: "agent-1" };
const options: TaskDocumentEditorOptions = {
imageUploadHandler: vi.fn(async () => "/api/attachments/conversation-image/content"),
mentions: [{ id: "agent-1", name: "Agent", kind: "agent" }],
externalReferences: {}, onVote: vi.fn(async () => {}), userProfileMap: new Map(),
};
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={queryClient}>
<TaskGeneralDocuments issue={conversation} documentEditor={options} />
</QueryClientProvider>));
await waitFor(() => expect(documentEditor).toHaveBeenCalledWith(expect.objectContaining({
...options, issue: conversation, generalDocumentsOnly: true, canDeleteDocuments: true, canManageDocumentLocks: true,
})));
expect(container.querySelectorAll('[data-testid="general-document-editor"]')).toHaveLength(1);
});
it("keeps the same general document authoring with the legacy artifact cards", async () => {
editorOptions = {};
artifactsV1.enabled = false;
mockUseIssueDocuments.mockReturnValue({ data: [planDocument, { ...planDocument, id: "note", key: "notes", title: "Task note" }] });
render();
await waitFor(() => expect(container.textContent).toContain("acmehats-interface-craft.pdf"));
expect(container.querySelectorAll('[data-testid="general-document-editor"]')).toHaveLength(1);
expect(container.textContent).not.toContain("Task note");
expect(container.textContent).toContain("plan.md");
expect(container.textContent).toContain("contact-sheet.png");
});
it("lists every artifact as one flat set of V1 rows, newest first, with no run sections", async () => {
render();
await waitFor(() => {
@@ -91,6 +91,8 @@ export function AgentBasicsDialog({
});
const nativeRunner = adapters?.find(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
const copilotAvailable = Boolean(nativeRunner) && PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "copilot" && profile.qualified);
const kimiAvailable = Boolean(nativeRunner) && experimental.data?.enableNativeRunner === true
&& PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "kimi" && profile.qualified);
const antigravityQualified = PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "antigravity" && profile.qualified);
const antigravityCandidate = nativeRunner?.candidateHarnesses?.some(candidate => candidate.agent === "antigravity");
const antigravityAvailable = Boolean(nativeRunner);
@@ -106,7 +108,8 @@ export function AgentBasicsDialog({
!["process", "http"].includes(adapter.type) &&
!getAdapterDisplay(adapter.type).comingSoon,
), ...(copilotAvailable ? [{ ...nativeRunner!, type: "copilot_runtime" }] : []),
...(antigravityAvailable ? [{ ...nativeRunner!, type: "antigravity_runtime" }] : [])];
...(antigravityAvailable ? [{ ...nativeRunner!, type: "antigravity_runtime" }] : []),
...(kimiAvailable ? [{ ...nativeRunner!, type: "kimi_runtime" }] : [])];
const experimentalRunnerAvailable = experimental.data?.enableNativeRunner === true
&& adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
const validAdapter = choices.some((adapter) => adapter.type === adapterType)
@@ -131,16 +134,16 @@ export function AgentBasicsDialog({
agent = await agentsApi.create(companyId, {
setupId: setupId.current, name: name.trim(), appearance: appearanceDraft.appearance,
role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}),
adapterType: ["copilot_runtime", "antigravity_runtime"].includes(provisionalAdapter) ? "paperclip_runner" : provisionalAdapter,
...(["copilot_runtime", "antigravity_runtime"].includes(provisionalAdapter) ? { adapterConfig: { provider: "acpx", acpxAgent: provisionalAdapter === "copilot_runtime" ? "copilot" : "antigravity" } } : {}),
adapterType: ["copilot_runtime", "antigravity_runtime", "kimi_runtime"].includes(provisionalAdapter) ? "paperclip_runner" : provisionalAdapter,
...(["copilot_runtime", "antigravity_runtime", "kimi_runtime"].includes(provisionalAdapter) ? { adapterConfig: { provider: "acpx", acpxAgent: provisionalAdapter === "copilot_runtime" ? "copilot" : provisionalAdapter === "kimi_runtime" ? "kimi" : "antigravity" } } : {}),
metadata: { setupAdapterPending: true },
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
});
if (active.current) setName(agent.name);
}
saved.current = agent;
const nextAdapter = ["openai_dot", "copilot_runtime", "antigravity_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType;
const nextProvider = adapterType === "antigravity_runtime" ? "antigravity" : adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex";
const nextAdapter = ["openai_dot", "copilot_runtime", "antigravity_runtime", "kimi_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType;
const nextProvider = adapterType === "kimi_runtime" ? "kimi" : adapterType === "antigravity_runtime" ? "antigravity" : adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex";
// Pending providers can show setup while the saved name-only draft stays held.
const pendingAntigravity = nextProvider === "antigravity" && !antigravityQualified;
if (step === "adapter" || (!creatingRecord && agent.name !== name.trim())) {
@@ -154,8 +157,8 @@ export function AgentBasicsDialog({
...(step === "adapter" && !pendingAntigravity ? {
adapterType: nextAdapter, runner: "auto" as const,
adapterConfig: nextAdapter === "paperclip_runner" ? {
provider: ["claude", "grok", "copilot", "antigravity"].includes(nextProvider) ? "acpx" : nextProvider,
...(["claude", "grok", "copilot", "antigravity"].includes(nextProvider) ? { acpxAgent: nextProvider } : {}),
provider: ["claude", "grok", "copilot", "antigravity", "kimi"].includes(nextProvider) ? "acpx" : nextProvider,
...(["claude", "grok", "copilot", "antigravity", "kimi"].includes(nextProvider) ? { acpxAgent: nextProvider } : {}),
...(nextProvider === "grok" ? { model: "grok-4.7" } : {}),
} : {},
metadata: { ...metadata, setupAdapterPending: false },
+19 -14
View File
@@ -134,8 +134,9 @@ function Setup({
const [runner, setRunner] = useState<AgentRunnerChoice>(initialRunner === "legacy" || initialRunner === "paperclip" ? initialRunner : "auto");
const isRunner = adapterType === "paperclip_runner";
const isDot = isRunner && runnerProvider === "openai_dot";
const isKimi = isRunner && runnerProvider === "kimi";
const brandType = isDot ? "openai_dot" : isRunner
? runnerProvider === "antigravity"
? isKimi ? "kimi_runtime" : runnerProvider === "antigravity"
? "antigravity_runtime"
: runnerProvider === "copilot"
? "copilot_runtime"
@@ -158,7 +159,7 @@ function Setup({
const providerKeys = setupProviderKeys(brandType);
const chooseProvider = multiProvider || brandType === "hermes_local";
const hasCredentialField =
chooseProvider || Boolean(SETUP_CREDENTIAL_KEYS[adapterType]);
isKimi || chooseProvider || Boolean(SETUP_CREDENTIAL_KEYS[adapterType]);
const showModel = !isDot && !["cursor_cloud", "hermes_gateway"].includes(adapterType);
const [allowUnmeteredProvider, setAllowUnmeteredProvider] = useState(false);
const [gatewayUrl, setGatewayUrl] = useState("");
@@ -338,7 +339,8 @@ function Setup({
const accountModelScope = JSON.stringify([companyId, environmentId, aiBinding]);
const models = useQuery({
queryKey: queryKeys.agents.adapterModels(companyId, brandType, environmentId, accountBoundCatalog ? JSON.stringify(aiBinding ?? null) : aiBinding?.provider),
queryFn: () => agentsApi.adapterModels(companyId, accountBoundCatalog ? "paperclip_runner" : brandType, accountBoundCatalog
queryFn: () => agentsApi.adapterModels(companyId, isKimi || accountBoundCatalog ? "paperclip_runner" : brandType, isKimi
? { provider: "acpx", acpxAgent: "kimi", environmentId } : accountBoundCatalog
? { provider: "acpx", acpxAgent: runnerProvider, aiConnection: aiBinding, environmentId }
: { provider: aiBinding?.provider, environmentId }),
...(accountBoundCatalog ? ACCOUNT_BOUND_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
@@ -379,7 +381,7 @@ function Setup({
environment?.driver === "sandbox" &&
caps.data?.sandboxProviders?.[sandboxProvider]?.supportsLoginPty === true;
const envKey =
SETUP_CREDENTIAL_KEYS[adapterType] ?? providerKeys[provider] ?? "API_KEY";
isKimi ? "KIMI_API_KEY" : SETUP_CREDENTIAL_KEYS[adapterType] ?? providerKeys[provider] ?? "API_KEY";
const savedKey = userSecrets.data?.find(
(entry) => entry.definition.key === envKey && entry.secret,
);
@@ -473,9 +475,9 @@ function Setup({
...(isRunner
? {
adapterSchemaValues: {
provider: ["claude", "grok", "copilot", "pi", "antigravity"].includes(runnerProvider) ? "acpx" : runnerProvider,
provider: ["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? "acpx" : runnerProvider,
...(isDot ? { allowUnmeteredProvider } : {}),
...(["claude", "grok", "copilot", "pi", "antigravity"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
...(["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
},
}
: {}),
@@ -493,8 +495,8 @@ function Setup({
}
if (isRunner)
Object.assign(config, {
provider: ["claude", "grok", "copilot", "pi", "antigravity"].includes(runnerProvider) ? "acpx" : runnerProvider,
...(["claude", "grok", "copilot", "pi", "antigravity"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
provider: ["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? "acpx" : runnerProvider,
...(["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
...(model ? { model } : {}),
});
if (multiProvider && created) {
@@ -542,6 +544,8 @@ function Setup({
return config;
}
function preparedConfig(nextConnection = connection, setupRunner?: AgentRunner) {
if (isKimi && !model.trim()) throw new Error("Enter the exact Kimi model ID.");
if (isKimi && !apiKey.trim() && !selectedBinding) throw new Error("Enter KIMI_API_KEY or select an organization secret.");
if (brandType === "antigravity_runtime" && (!model.trim() || model !== model.trim() || /\s/.test(model) || ["auto", "default"].includes(model.toLowerCase())))
throw new Error("Choose or enter an exact Google Antigravity model ID.");
if (brandType === "copilot_runtime" && !models.data?.some(candidate => candidate.id === model))
@@ -601,8 +605,8 @@ function Setup({
role: existing.length ? "general" : "ceo", ...(leader ? { reportsTo: leader.id } : {}),
adapterType: requestAdapterType, runner,
adapterConfig: requestAdapterType === "paperclip_runner" ? {
provider: ["claude", "grok", "copilot", "pi", "antigravity"].includes(runnerProvider) ? "acpx" : runnerProvider,
...(["claude", "grok", "copilot", "pi", "antigravity"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
provider: ["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? "acpx" : runnerProvider,
...(["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
...(antigravityCandidate ? { model: antigravityCandidate.model } : {}),
} : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
@@ -1222,8 +1226,8 @@ function Setup({
}}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={!multiProvider && !accountBoundCatalog}
required={multiProvider || accountBoundCatalog}
allowDefault={!isKimi && !multiProvider && !accountBoundCatalog}
required={isKimi || multiProvider || accountBoundCatalog}
creatable={brandType !== "copilot_runtime"}
groupByProvider={multiProvider && !connectionModels}
preserveOrder={Boolean(connectionModels) || adapterCuratesModelOrder(brandType)}
@@ -1314,10 +1318,10 @@ function Setup({
placeholder={
selectedBinding
? "Using saved key"
: [
: (isKimi || [
"cursor_cloud",
"hermes_gateway",
].includes(adapterType)
].includes(adapterType))
? "Required"
: "Optional if already configured"
}
@@ -1368,6 +1372,7 @@ function Setup({
<p className="text-xs text-muted-foreground sm:col-span-2">
New keys are saved as organization secrets when
you finish setup.
{isKimi && " Use a Kimi Code API key for this runner."}
{multiProvider && ` Use a ${provider}/model ID.`}
</p>
</div>
@@ -1,4 +1,6 @@
import { CoordinatorRelativeTime } from "@/components/coordinator/CoordinatorRelativeTime";
import { useAgentChatRead } from "@/hooks/useAgentChatRead";
import { Link } from "@/lib/router";
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
@@ -147,6 +149,7 @@ function TaskChatBubbleContent({
onTryAgainNoLiveExecutionPath,
tryAgainNoLiveExecutionPathPending,
}: TaskChatBubbleProps) {
const readRef = useAgentChatRead(item.id, item.createdAtIso, item.author === "agent" && !item.optimistic);
const streamlined = useStreamlinedTaskChatPresentation();
const coordinator = useCoordinatorPresentation();
const recordedTimestamp = item.createdAtIso ?? (typeof item.atMs === "number" && Number.isFinite(item.atMs) ? new Date(item.atMs) : null);
@@ -229,6 +232,7 @@ function TaskChatBubbleContent({
return (
<div
data-conversation-message={isHuman ? "human" : "agent"}
ref={readRef}
className={cn(
"flex w-full flex-col gap-1",
animateEntry && "tc-enter-bubble",
@@ -236,6 +240,9 @@ function TaskChatBubbleContent({
)}
>
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
{item.chatMessage && <Link to={`/issues/${item.chatMessage.sourceTaskIdentifier}`} className="px-1 text-xs text-muted-foreground underline underline-offset-4">
From {item.chatMessage.sourceTaskIdentifier}
</Link>}
{isHuman && !item.isCurrentUser && item.authorName ? (
<span className="px-1 text-sm font-semibold text-foreground" data-testid="task-chat-human-identity" data-author-user-id={item.authorUserId}>
<Identity name={item.authorName} avatarUrl={paperclipHumanAvatarUrl(item.authorAvatarUrl)} size="default" className="gap-2" />
@@ -0,0 +1,27 @@
import { ArrowUpRight, ListTree } from "lucide-react";
import { Link } from "@/lib/router";
import { IssueStatusBadge } from "@/components/StatusBadge";
import type { TaskChatTaskCreatedItem } from "./task-chat-model";
export function TaskChatTaskCreatedCard({ item }: { item: TaskChatTaskCreatedItem }) {
const { task } = item;
return (
<Link
to={`/issues/${task.identifier ?? task.id}`}
aria-label={`Open ${task.identifier ?? "task"}: ${task.title}`}
data-testid="task-chat-created-task"
className="flex items-start gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ListTree className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 space-y-2">
<span className="block text-xs text-muted-foreground">{item.isSubtask ? "Subtask created" : "Task created"}</span>
<span className="block break-words text-sm font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-2">
{task.identifier && <span className="font-mono text-xs text-muted-foreground">{task.identifier}</span>}
<IssueStatusBadge status={task.status} />
</span>
</span>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
);
}
@@ -1,6 +1,7 @@
import { TaskBrowserActivity } from "../task-side-panel/TaskBrowserActivity";
import { TaskChatProjectCreatedCard } from "./TaskChatProjectCreatedCard";
import { TaskChatSkillCreatedCard } from "./TaskChatSkillCreatedCard";
import { TaskChatTaskCreatedCard } from "./TaskChatTaskCreatedCard";
import { useMemo, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
@@ -111,6 +112,7 @@ function renderItem(
case "browser": return <TaskBrowserActivity browser={item.browser} label={item.label} onOpen={onOpenBrowser} />;
case "project_created": return <TaskChatProjectCreatedCard item={item} />;
case "skill_created": return <TaskChatSkillCreatedCard item={item} onOpen={onOpenSkill} />;
case "task_created": return <TaskChatTaskCreatedCard item={item} />;
case "message": {
// Compute the actions once: the bubble renders them for a runless reply
// (footer = actions + timestamp), while an attached turn hands them to
@@ -29,6 +29,13 @@ describe("composer run settings", () => {
expect(composerEfforts(native, "grok-4.7", ["grok-4.7"])).toEqual(["low", "medium", "high", "xhigh"]);
expect(readComposerRunSettings({ adapterConfig: native.adapterConfig }, native.adapterType)).toMatchObject({ effort: "high" });
});
it("preserves the explicit native Kimi model without inventing a default or effort control", () => {
const native = { ...agent("paperclip_runner", "acpx"), adapterConfig: { provider: "acpx", acpxAgent: "kimi" } };
expect(composerDefaultModel(native)).toBe("");
const configured = { ...native, adapterConfig: { ...native.adapterConfig, model: "exact-kimi-model" } };
expect(composerDefaultModel(configured)).toBe("exact-kimi-model");
expect(composerEfforts(configured, "exact-kimi-model", ["exact-kimi-model"])).toEqual([]);
});
it("shows only effort levels known for the selected harness and model", () => {
expect(composerEfforts(agent("codex_local"), "gpt-6-astra", [])).toContain("ultra");
expect(composerEfforts(agent("paperclip_runner", "codex"), "gpt-6-astra", [])).toContain("ultra");
@@ -46,6 +46,7 @@ export function composerDefaultModel(agent: Agent | undefined): string {
const provider = composerCatalogProvider(agent);
if (provider === "codex" || provider === "opencode") return resolvePaperclipRunnerModel(provider, config.model);
if (provider === "acpx" && config.acpxAgent === "grok") return "grok-4.7";
if (provider === "acpx" && config.acpxAgent === "kimi") return "";
if (provider === "acpx" && config.acpxAgent !== "grok") return resolvePaperclipRunnerModel(provider, config.model);
}
return "";
@@ -136,6 +136,7 @@ export function commentsToTaskChatItems(
agent: effectiveAgentId(comment) ? ctx.agentMap?.get(effectiveAgentId(comment)!) ?? { id: effectiveAgentId(comment)! } : undefined,
text: comment.body,
origin: comment.origin ?? "comment",
chatMessage: comment.metadata?.chatMessage,
sourceChannel: kind === "human" ? comment.metadata?.sourceChannel : undefined,
timestamp: formatTaskChatCommentTimestamp(comment, kind),
optimistic,
@@ -13,6 +13,7 @@
* CSS motion tokens in ui/src/index.css.
*/
import type {
Issue,
TaskBrowser,
IssueAttachment,
IssueCommentMetadata,
@@ -165,6 +166,7 @@ export interface TaskChatMessageItem {
*/
presentation?: IssueCommentPresentation | null;
metadata?: IssueCommentMetadata | null;
chatMessage?: IssueCommentMetadata["chatMessage"];
/** Agent that owns the source run, used to build run-detail links in metadata rows. */
runAgentId?: string | null;
/** Raw comment timestamp (ISO) — the collapsed system row shows relative time. */
@@ -555,6 +557,14 @@ export interface TaskChatTurnItem {
};
}
export interface TaskChatTaskCreatedItem {
id: string;
kind: "task_created";
task: Pick<Issue, "id" | "identifier" | "title" | "status">;
isSubtask: boolean;
timestamp: string;
}
export interface TaskChatProjectCreatedItem {
id: string;
kind: "project_created";
@@ -589,6 +599,7 @@ export type TaskChatItem =
| { id: string; kind: "decision_receipt"; receipt: DecisionHistoryItem }
| { id: string; kind: "task_handoff"; handoff: ConversationTaskHandoff; animateEntry?: boolean }
| TaskChatBrowserItem
| TaskChatTaskCreatedItem
| TaskChatProjectCreatedItem
| TaskChatSkillCreatedItem
| TaskChatMessageItem
@@ -0,0 +1,23 @@
import type { Issue } from "@paperclipai/shared";
import type { TaskChatTaskCreatedItem } from "./task-chat-model";
/** Tasks come from the existing access-checked child/source-task reads, never tool output. */
export function taskCreatedItems(
tasks: readonly Issue[],
issueId: string | null,
companyId: string | null | undefined,
representedTaskIds: ReadonlySet<string>,
): TaskChatTaskCreatedItem[] {
const seen = new Set(representedTaskIds);
return tasks.flatMap((task) => {
if (!task.createdByAgentId || task.id === issueId || seen.has(task.id) || task.companyId !== companyId) return [];
seen.add(task.id);
return [{
id: `task-created:${task.id}`,
kind: "task_created" as const,
task: { id: task.id, identifier: task.identifier, title: task.title, status: task.status },
isSubtask: task.parentId === issueId,
timestamp: new Date(task.createdAt).toISOString(),
}];
});
}
@@ -67,8 +67,17 @@ vi.mock("@/components/IssueProperties", () => ({
IssueProperties: () => <div>Properties content</div>,
}));
const artifactsTabRender = vi.hoisted(() => vi.fn());
const generalDocumentsRender = vi.hoisted(() => vi.fn());
vi.mock("@/components/issue-properties/IssuePropertiesArtifactsTab", () => ({
IssuePropertiesArtifactsTab: () => <div>Artifacts content</div>,
IssuePropertiesArtifactsTab: (props: unknown) => {
artifactsTabRender(props);
return <div>Artifacts content</div>;
},
TaskGeneralDocuments: (props: unknown) => {
generalDocumentsRender(props);
return <div data-testid="conversation-document-editor">Conversation documents</div>;
},
}));
vi.mock("@/components/issue-properties/IssuePropertiesPlansTab", () => ({
@@ -140,6 +149,8 @@ describe("TaskSidePanel", () => {
let queryClient: QueryClient;
beforeEach(() => {
artifactsTabRender.mockClear();
generalDocumentsRender.mockClear();
coordinator.enabled = false;
window.localStorage.clear();
browserFixture.data = [];
@@ -192,6 +203,18 @@ describe("TaskSidePanel", () => {
);
}
it.each([false, true])("owns document authoring in the resolved chat shell (coordinator: %s)", async coordinatorEnabled => {
coordinator.enabled = coordinatorEnabled;
const documentEditor = { imageUploadHandler: vi.fn(async () => "/api/attachments/image/content"), externalReferences: {} };
await render(panel({ documentEditor }));
const open = container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]');
if (open) await act(async () => open.click());
const artifacts = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(item => item.textContent?.includes("Artifacts"));
expect(artifacts).toBeTruthy();
await act(async () => artifacts!.click());
expect(artifactsTabRender).toHaveBeenCalledWith(expect.objectContaining({ documentEditor }));
});
it("offers idle plugin views in the launcher without opening them automatically", async () => {
await render(panel({ pluginPanels: [{ ...pluginPanel, runId: undefined, autoOpen: false }] }));
expect(container.textContent).not.toContain("Plugin viewer");
@@ -481,6 +504,30 @@ describe("TaskSidePanel", () => {
expect(container.textContent).not.toContain("Artifacts content");
});
it.each([
{ coordinatorEnabled: false, mobile: false },
{ coordinatorEnabled: false, mobile: true },
{ coordinatorEnabled: true, mobile: false },
{ coordinatorEnabled: true, mobile: true },
])("keeps conversation document authoring beside agent artifacts ($coordinatorEnabled, $mobile)", async ({ coordinatorEnabled, mobile }) => {
coordinator.enabled = coordinatorEnabled;
const conversation = issue({ conversationAgentId: "agent-1" });
const documentEditor = {
imageUploadHandler: vi.fn(async () => "/api/attachments/conversation-image/content"),
mentions: [{ id: "agent-1", name: "Agent", kind: "agent" as const }],
externalReferences: {}, onVote: vi.fn(async () => {}), userProfileMap: new Map(),
};
await render(panel({ issue: conversation, documentEditor, mobile }));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const artifacts = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(item => item.textContent?.includes("Artifacts"));
expect(artifacts).toBeTruthy();
await act(async () => artifacts!.click());
expect(container.querySelectorAll('[data-testid="conversation-document-editor"]')).toHaveLength(1);
expect(generalDocumentsRender).toHaveBeenCalledWith(expect.objectContaining({ issue: conversation, documentEditor }));
expect(container.textContent).toContain("Agent artifacts agent-1");
expect(artifactsTabRender).not.toHaveBeenCalled();
});
it("keeps a chat on the conversation's own Artifacts while Agent Chat is off", async () => {
agentChat.enabled = false;
try {
@@ -41,7 +41,7 @@ import { PROPERTIES_PANE_HEADER_SLOT_ID } from "@/components/PropertiesPanel";
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import { AgentArtifactsPanel, AgentTasksPanel } from "@/components/chat/AgentWorkPanels";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
import { IssuePropertiesArtifactsTab, TaskGeneralDocuments, type TaskDocumentEditorOptions } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
import { ArtifactDropzone } from "@/components/artifacts/ArtifactDropzone";
import { ArtifactUploadButton } from "@/components/artifacts/ArtifactUploadControls";
import { useIssueArtifactUploads } from "@/hooks/useIssueArtifactUploads";
@@ -101,6 +101,7 @@ import { TaskSkillPanel } from "./TaskSkillPanel";
export interface TaskSidePanelProps {
issue: Issue;
documentEditor?: TaskDocumentEditorOptions;
pluginPanels?: TaskPluginPanel[];
pluginPanelsLoading?: boolean;
openedPluginPanelKeys?: Set<string>;
@@ -265,6 +266,7 @@ function TaskSidePanelForScope({
openBrowserId,
onBrowserOpened,
issue,
documentEditor,
accountScope,
childIssues = [],
issueLinkState,
@@ -838,13 +840,20 @@ function TaskSidePanelForScope({
: <AgentTasksPanel companyId={issue.companyId} agentId={conversationAgentId} excludeIssueId={issue.id} />
) : null;
} else if (activeTab.payload.kind === "artifacts" && conversationAgentId) {
content = <AgentArtifactsPanel companyId={issue.companyId} agentId={conversationAgentId} />;
content = (
<div className="space-y-4">
<section aria-label="Conversation documents">
<TaskGeneralDocuments issue={issue} documentEditor={documentEditor ?? {}} />
</section>
<AgentArtifactsPanel companyId={issue.companyId} agentId={conversationAgentId} />
</div>
);
} else if (activeTab.payload.kind === "artifacts") {
content = <TextAttachmentContext.Provider value={(id, title) => {
markInteracted();
controller.openTab(taskPanelAttachmentTab(id, title));
if (viewer.state || viewer.browse) viewer.close();
}}><IssuePropertiesArtifactsTab issue={issue} onOpenDocument={openDocument} uploadState={artifactUploads} /></TextAttachmentContext.Provider>;
}}><IssuePropertiesArtifactsTab issue={issue} documentEditor={documentEditor ?? {}} onOpenDocument={openDocument} uploadState={artifactUploads} /></TextAttachmentContext.Provider>;
} else if (activeTab.payload.kind === "issue-document") {
content = activeTab.payload.documentKey === "plan" && !activeTab.payload.revisionId && !activeTab.payload.documentIssueId ? (
<IssuePropertiesPlansTab issue={issue} inline={inline} />
@@ -0,0 +1 @@
{"provenance":{"campaign":"local-2026-10-29T13-45-22-982Z","cell":"kimi-qualification/runner-acpx-kimi/local/native-write-permission-stop/attempt-1","source":"snapshots/api-state.json","sourceSha256":"bec6f6ddcbc5a0d68ad130f33e7d8d206c29d42b8c64a8a555a46d117b1e2ed9","selection":"Assistant deltas, runtime request lifecycle, and provider turn terminal; event rows preserved without modification."},"run":{"id":"3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56","status":"cancelled"},"events":[{"id":37,"companyId":"fca72f94-9495-4730-a35f-9524506819d0","runId":"3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56","agentId":"e694fde7-335f-4094-8ae1-f7b2aaed4f12","seq":37,"eventType":"item.delta","stream":"system","level":"info","color":null,"message":null,"payload":{"prpEvent":{"runId":"3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56","itemId":"acpx-assistant-82f602d2a9b187dc78734f41f7e466a72696f26ac5fe833f8fde19cc4aeff804","schema":"paperclip.prp.event.v1","turnId":"turn_lab_dfcc5b7f413e4171819cbc8b70249ac6","payload":{"kind":"agentMessage","text":"","update":{"kind":"agentMessage","text":"","delta":"","itemId":"acpx-assistant-82f602d2a9b187dc78734f41f7e466a72696f26ac5fe833f8fde19cc4aeff804","turnId":"turn_lab_dfcc5b7f413e4171819cbc8b70249ac6","channel":"progress","provider":"acpx","threadId":"paperclip-23b917558bef54de9b9b83414cbac5f5cc1f211f26d5e5a7c3e664ff46c3677b","providerItemId":"kimi-message-6cf6835a988c62b9ba9d4fe943d2ba7da14221c6e4e717cfeee574fae03cecab","providerMethod":"runtime.event"},"channel":"unknown","providerMethod":"item/agentMessage/delta"},"priority":1,"emittedAt":"2026-10-29T13:45:55.106Z","eventType":"item.delta","sourceSeq":10,"sourceKind":"runner","schemaVersion":1,"sourceEventId":"98c5a554-f7e8-45de-a6f5-6c373b19c18d:3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56:10","sourceInstanceId":"98c5a554-f7e8-45de-a6f5-6c373b19c18d","normalizedSessionId":"91fa84b1-572b-4258-bf60-ba4e96a1e4ba"}},"sourceInstanceId":"98c5a554-f7e8-45de-a6f5-6c373b19c18d","sourceEventId":"98c5a554-f7e8-45de-a6f5-6c373b19c18d:3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56:10","sourceSeq":10,"sourcePayloadSha256":"15547d7830dabf9f1060c42f50b916031ecb577e87545fbc636abf316cd15f70","protocolSchemaVersion":1,"createdAt":"2026-10-29T13:45:55.107Z"},{"id":38,"companyId":"fca72f94-9495-4730-a35f-9524506819d0","runId":"3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56","agentId":"e694fde7-335f-4094-8ae1-f7b2aaed4f12","seq":38,"eventType":"item.delta","stream":"system","level":"info","color":null,"message":null,"payload":{"prpEvent":{"runId":"3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56","itemId":"acpx-assistant-82f602d2a9b187dc78734f41f7e466a72696f26ac5fe833f8fde19cc4aeff804","schema":"paperclip.prp.event.v1","turnId":"turn_lab_dfcc5b7f413e4171819cbc8b70249ac6","payload":{"kind":"agentMessage","text":"I'll","update":{"kind":"agentMessage","text":"I'll","delta":"I'll","itemId":"acpx-assistant-82f602d2a9b187dc78734f41f7e466a72696f26ac5fe833f8fde19cc4aeff804","turnId":"turn_lab_dfcc5b7f413e4171819cbc8b70249ac6","channel":"progress","provider":"acpx","threadId":"paperclip-23b917558bef54de9b9b83414cbac5f5cc1f211f26d5e5a7c3e664ff46c3677b","providerItemId":"kimi-message-6cf6835a988c62b9ba9d4fe943d2ba7da14221c6e4e717cfeee574fae03cecab","providerMethod":"runtime.event"},"channel":"unknown","providerMethod":"item/agentMessage/delta"},"priority":1,"emittedAt":"2026-10-29T13:46:00.313Z","eventType":"item.delta","sourceSeq":11,"sourceKind":"runner","schemaVersion":1,"sourceEventId":"98c5a554-f7e8-45de-a6f5-6c373b19c18d:3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56:11","sourceInstanceId":"98c5a554-f7e8-45de-a6f5-6c373b19c18d","normalizedSessionId":"91fa84b1-572b-4258-bf60-ba4e96a1e4ba"}},"sourceInstanceId":"98c5a554-f7e8-45de-a6f5-6c373b19c18d","sourceEventId":"98c5a554-f7e8-45de-a6f5-6c373b19c18d:3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56:11","sourceSeq":11,"sourcePayloadSha256":"07a76f399becfc77a6a2cf6adbfd9ce829c254893e9077bffdfc9affae254870","protocolSchemaVersion":1,"createdAt":"2026-10-29T13:46:00.314Z"},{"id":41,"companyId":"fca72f94-9495-4730-a35f-9524506819d0","runId":"3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56","agentId":"e694fde7-335f-4094-8ae1-f7b2aaed4f12","seq":41,"eventType":"item.delta","stream":"system","level":"info","color":null,"message":null,"payload":{"prpEvent":{"runId":"3cf126bf-b5ab-4e90-a4d6-d07c4a21ab56","itemId":"acpx-assistant-82f602d2a9b187dc78734f41f7e466a72696f26ac5fe833f8fde19cc4aeff804","schema":"paperclip.prp.event.v1","turnId":"turn_lab_dfcc5b7f413e4171819cbc8b70249ac6","payload":{"kind":"agentMessage","text":" use","update":{"kind":"agentMessage","text":" use","delta":" use","itemId":"acpx-assistant-82f602d2a9b187dc78734f41f7e466a72696f26ac5fe833f8fde19cc4aeff804","turnId":"turn_lab_dfcc5b7f413e4171819cbc8b70249ac6","channel":"progress","provider":"acpx","threadId":"paperclip-23b917558bef54de9b9b83414cbac5f5cc1f211f26d5e5a7c3e664ff46c3677b","providerItemId":"kimi-message-6cf6835a988c62b9ba9d4fe943d2ba7da14221c6e4e717cfeee574fae03cecab","providerMethod":"runtime.event"},"channel":"unknown","providerMethLine truncated
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
import { nativeRunEventsToTranscript } from "./native-run-events";
import { paperclipRunnerFinalResponse, settledRunChildren, transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
const RUN_ID = "10000000-0000-4000-8000-000000000001";
@@ -525,6 +526,50 @@ describe("nativeRunEventsToTranscript", () => {
]);
});
it("keeps retained stopped output as progress without fabricating a final response", () => {
const fixture = JSON.parse(readFileSync(
new URL("./fixtures/stopped-native-progress.json", import.meta.url),
"utf8",
)) as { run: { id: string; status: string }; events: HeartbeatRunEvent[] };
const entries = nativeRunEventsToTranscript(fixture.events);
const parsed = transcriptToTaskChatItems(entries, { runId: fixture.run.id, running: false });
expect(fixture.run.status).toBe("cancelled");
expect(paperclipRunnerFinalResponse(parsed, { allowFallback: true })).toBeUndefined();
expect(parsed.filter((item) => item.kind === "message")).toEqual([
expect.objectContaining({
channel: "progress",
interstitial: true,
text: "I'll use the native Write tool once for the exact path, leaving the permission request pending.",
}),
]);
const children = settledRunChildren(parsed);
expect(children.filter((item) => item.kind === "activity_phase" && item.interstitial)).toHaveLength(1);
expect(children.filter((item) => item.kind === "protocol" && item.surface === "runtime_request")).toEqual([
expect.objectContaining({ status: "cancelled" }),
]);
});
it.each([
["final", "progress", "final"],
["progress", "final", "progress"],
["unknown", "final", "unknown"],
["unknown", "invalid", "unknown"],
["unknown", null, "unknown"],
[undefined, "progress", "progress"],
] as const)("preserves outer channel %s with nested channel %s", (outer, nested, expected) => {
expect(nativeRunEventsToTranscript([
itemEvent(1, "item.delta", "message-1", {
kind: "agentMessage",
...(outer === undefined ? {} : { channel: outer }),
text: "Provider message.",
update: { channel: nested },
}),
])).toEqual([
expect.objectContaining({ kind: "assistant", channel: expected }),
]);
});
it("streams deltas until a loss-resistant completed item is available", () => {
expect(nativeRunEventsToTranscript([
event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Still " }),
@@ -50,7 +50,9 @@ function assistantChannel(
): "progress" | "final" | "unknown" {
const item = normalizedItem(payload);
const value = text(payload.channel) ?? text(item.channel);
if (value === "progress" || value === "final" || value === "unknown") return value;
if (value === "progress" || value === "final") return value;
if ((!value || value === "unknown") && record(payload.update)?.channel === "progress") return "progress";
if (value === "unknown") return value;
return fallback;
}
@@ -16,6 +16,20 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
import { queryKeys } from "../lib/queryKeys";
describe("LiveUpdatesProvider issue invalidation", () => {
it("discovers first proactive deliveries without invalidating another human's chat list", () => {
const client = new QueryClient();
const mine = queryKeys.agentChats.list("company-1", "user-1");
const theirs = queryKeys.agentChats.list("company-1", "user-2");
client.setQueryData(mine, []);
client.setQueryData(theirs, []);
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
entityType: "issue", entityId: "new-chat", action: "issue.comment_added",
details: { conversationUserId: "user-1", conversationAgentId: "kodi", commentId: "digest" },
}, { userId: "user-1", agentId: null });
expect(client.getQueryState(mine)?.isInvalidated).toBe(true);
expect(client.getQueryState(theirs)?.isInvalidated).toBe(false);
client.clear();
});
it.each(["issue.thread_interaction_accepted", "issue.thread_interaction_answered", "decision.decided", "decision.effect_succeeded", "decision.effect_failed", "issue.conversation_session_started", "inbox.dismissed", "inbox.snoozed", "inbox.restored"])("refreshes canonical attention, history and source-chat projections after %s", action => {
const client = new QueryClient();
const invalidate = vi.spyOn(client, "invalidateQueries");
+3 -1
View File
@@ -1444,8 +1444,10 @@ function invalidateActivityQueries(
if (entityType === "issue") {
const chatListKey = queryKeys.agentChats.list(companyId, currentActor.userId);
const knownChat = entityId && queryClient.getQueryData<Issue[]>(chatListKey)?.some(chat => chat.id === entityId);
if (knownChat || action === "issue.conversation_opened" && ownActorActivity) {
const ownChatDelivery = readString(details?.conversationUserId) === (currentActor.userId ?? "local-board");
if (knownChat || ownChatDelivery || action === "issue.conversation_opened" && ownActorActivity) {
queryClient.invalidateQueries({ queryKey: chatListKey });
queryClient.invalidateQueries({ queryKey: ["agent-chat", companyId, currentActor.userId] });
}
if (action === "issue.tree_hold_created" || action === "issue.tree_hold_released" || action === "issue.updated") {
// An ancestor hold or reparenting changes descendants' effective pause.
@@ -32,6 +32,7 @@ describe("Workspace service consent", () => {
}
beforeEach(() => {
vi.clearAllMocks();
listGrants.mockResolvedValue({ currentUserId: "me", grants: [] });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@@ -68,6 +69,36 @@ describe("Workspace service consent", () => {
expect(onContinue).toHaveBeenCalledWith({ gmail: "read" });
});
it("restores a cold draft's selected services after declined OAuth", async () => {
await render({ ...existing, companyId: "company-1", status: "draft", config: {
workspaceSetup: {
version: 1, companyId: "company-1", connectionId: existing.id,
actorType: "user", actorId: "me", grantKind: "user", subjectId: "me",
requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"],
},
oauth: { scopes: ["https://www.googleapis.com/auth/documents"] },
} });
expect([...document.querySelectorAll('[role="switch"]')].map(item => item.getAttribute("aria-checked"))).toEqual(["true", "false", "false"]);
expect(document.querySelector("select")?.value).toBe("read");
await click("Continue");
expect(onContinue).toHaveBeenCalledWith({ gmail: "read" });
});
it.each([undefined, "another-user"])("requires a deliberate choice for an unbound draft (%s)", async (actorId) => {
await render({ ...existing, companyId: "company-1", status: "draft", config: {
...(actorId ? { workspaceSetup: {
version: 1, companyId: "company-1", connectionId: existing.id,
actorType: "user", actorId, grantKind: "user", subjectId: "me",
requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"],
} } : {}),
oauth: { scopes: ["https://www.googleapis.com/auth/documents"] },
} });
expect([...document.querySelectorAll('[role="switch"]')].map(item => item.getAttribute("aria-checked"))).toEqual(["false", "false", "false"]);
const proceed = [...document.querySelectorAll("button")].find(item => item.textContent === "Continue")!;
expect(proceed.disabled).toBe(true);
expect(onContinue).not.toHaveBeenCalled();
});
it("blocks on grant-load failure and retries without broadening the saved request", async () => {
listGrants.mockRejectedValueOnce(new Error("unavailable"));
await render(existing);
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, GOOGLE_WORKSPACE_SERVICE_GROUPS, googleWorkspaceSelectionForScopes, type GoogleWorkspaceServiceSelection, type ToolConnection } from "@paperclipai/shared";
import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, GOOGLE_WORKSPACE_SERVICE_GROUPS, googleWorkspaceDraftRequestedScopes, googleWorkspaceSelectionForScopes, type GoogleWorkspaceServiceSelection, type ToolConnection } from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -43,12 +43,11 @@ type DialogProps = {
};
export function GoogleWorkspaceServicesDialog(props: DialogProps) {
const existing = props.connection && props.connection.status !== "draft" ? props.connection : null;
const existing = props.connection ?? null;
const oauth = props.connection?.config?.oauth as { pendingWorkspaceServiceRequests?: Record<string, { requestedScopes?: unknown; requestedAt?: string }> } | undefined;
const pendingRequests = oauth?.pendingWorkspaceServiceRequests;
const loadConnection = existing ?? (pendingRequests && Object.keys(pendingRequests).length ? props.connection : null);
const grants = useQuery({ queryKey: ["workspace-service-consent", loadConnection?.id], queryFn: () => toolsApi.listConnectionGrants(loadConnection!.id), enabled: Boolean(loadConnection) });
if (loadConnection && !grants.data) return <Dialog open onOpenChange={open => { if (!open) props.onCancel(); }}><DialogContent>
const grants = useQuery({ queryKey: ["workspace-service-consent", existing?.id], queryFn: () => toolsApi.listConnectionGrants(existing!.id), enabled: Boolean(existing) });
if (existing && !grants.data) return <Dialog open onOpenChange={open => { if (!open) props.onCancel(); }}><DialogContent>
<DialogTitle>Connect Google Workspace</DialogTitle><DialogDescription>{grants.isError ? "Could not load your saved service access. Your connection has not changed." : "Loading your saved service access…"}</DialogDescription>
<footer className="flex items-center justify-between gap-3"><Button variant="ghost" onClick={() => props.onCancel()}>Cancel</Button>{grants.isError && <Button onClick={() => void grants.refetch()}>Try again</Button>}</footer>
</DialogContent></Dialog>;
@@ -57,7 +56,15 @@ export function GoogleWorkspaceServicesDialog(props: DialogProps) {
: existing?.credentialPolicy === "per_agent"
? grant.kind === "agent" && grant.subjectAgentId === (existing.config?.oauth as { connectorSubjectAgentId?: string } | undefined)?.connectorSubjectAgentId
: grant.kind === "organization");
const savedScopes = grant?.providerTenant?.oauth?.requestedScopes ?? grant?.providerTenant?.oauth?.scopes ?? [];
const savedScopes = existing?.status === "draft"
? googleWorkspaceDraftRequestedScopes(existing.config?.workspaceSetup, {
companyId: existing.companyId, connectionId: existing.id,
actorType: "user", actorId: grants.data?.currentUserId ?? null,
grantKind: existing.credentialPolicy === "per_user" ? "user" : existing.credentialPolicy === "per_agent" ? "agent" : "organization",
subjectId: existing.credentialPolicy === "per_user" ? grants.data?.currentUserId ?? null
: existing.credentialPolicy === "per_agent" ? (existing.config?.oauth as { connectorSubjectAgentId?: string } | undefined)?.connectorSubjectAgentId ?? null : null,
}) ?? grant?.providerTenant?.oauth?.requestedScopes ?? []
: grant?.providerTenant?.oauth?.requestedScopes ?? grant?.providerTenant?.oauth?.scopes ?? [];
const pending = grants.data?.currentUserId ? pendingRequests?.[grants.data.currentUserId] : undefined;
const pendingTime = Date.parse(pending?.requestedAt ?? "");
// Refresh and lease writes update the grant row without changing consent.
+158
View File
@@ -0,0 +1,158 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { AgentChatReadProvider, useAgentChatRead } from "./useAgentChatRead";
import { ApiError } from "@/api/client";
const markRead = vi.hoisted(() => vi.fn());
vi.mock("@/api/issues", () => ({ issuesApi: { markRead } }));
let root: Root, container: HTMLDivElement, client: QueryClient;
let focused = true, latest = true, visible = true;
const first = "11111111-1111-4111-8111-111111111111", second = "22222222-2222-4222-8222-222222222222";
function Message({ id }: { id: string }) {
const ref = useAgentChatRead(id, "2026-10-30T12:00:00.123Z", true);
return <div ref={ref}>Digest</div>;
}
async function render(ids = [first], companyId = "company-1", userId = "human-1", issueId = "chat-1") {
await act(async () => root.render(<QueryClientProvider client={client}>
<AgentChatReadProvider issueId={issueId} companyId={companyId} userId={userId}>
<div className="task-chat-scroll-viewport">{ids.map(id => <Message key={id} id={id} />)}</div>
</AgentChatReadProvider>
</QueryClientProvider>));
await act(async () => { window.dispatchEvent(new Event("focus")); });
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1));
vi.stubGlobal("cancelAnimationFrame", vi.fn());
focused = true; latest = true; visible = true;
vi.spyOn(document, "hasFocus").mockImplementation(() => focused);
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visible ? "visible" : "hidden");
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(() => latest ? 100 : 300);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(100);
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ top: 0, bottom: 100, height: 100, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON() {} });
markRead.mockReset().mockResolvedValue({});
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); client.clear(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
it("reads only visible foreground messages at the bottom, including arrivals sharing a millisecond", async () => {
focused = false; await render(); expect(markRead).not.toHaveBeenCalled();
focused = true; latest = false; await render(); expect(markRead).not.toHaveBeenCalled();
latest = true; await render(); expect(markRead).toHaveBeenLastCalledWith("chat-1", first);
await render([first, second]); expect(markRead).toHaveBeenLastCalledWith("chat-1", second);
await render([first, second]); expect(markRead).toHaveBeenCalledTimes(2);
});
it("does not clear concurrent unseen arrivals while a read request is pending", async () => {
let finish!: () => void;
markRead.mockImplementationOnce(() => new Promise<void>(resolve => { finish = resolve; }));
await render(); expect(markRead).toHaveBeenCalledTimes(1);
focused = false; await render([first, second]);
await act(async () => finish()); expect(markRead).toHaveBeenCalledTimes(1);
focused = true; await render([first, second]); expect(markRead).toHaveBeenLastCalledWith("chat-1", second);
});
it("automatically retries a failed visible message without another focus event", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
markRead.mockRejectedValueOnce(new TypeError("Network unavailable"));
await render();
expect(markRead).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(markRead).toHaveBeenCalledTimes(2);
expect(markRead).toHaveBeenLastCalledWith("chat-1", first);
expect(container.querySelector('[role="alert"]')).toBeNull();
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(markRead).toHaveBeenCalledTimes(2);
});
it("retries only the seen cutoff when another message arrives out of view", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
markRead.mockRejectedValueOnce(new TypeError("Network unavailable"));
await render();
focused = false; await render([first, second]);
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(markRead).toHaveBeenCalledTimes(2);
expect(markRead).toHaveBeenLastCalledWith("chat-1", first);
focused = true; await render([first, second]);
expect(markRead).toHaveBeenLastCalledWith("chat-1", second);
});
it("coalesces a newly seen message into the pending retry", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
markRead.mockRejectedValueOnce(new TypeError("Network unavailable"));
await render();
await render([first, second]);
expect(markRead).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(markRead).toHaveBeenCalledTimes(2);
expect(markRead).toHaveBeenLastCalledWith("chat-1", second);
await render([first, second]);
expect(markRead).toHaveBeenCalledTimes(2);
});
it("respects the server retry delay for a rate-limited read", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
markRead.mockRejectedValueOnce(new ApiError("Rate limited", 429, {}, { retryAfterMs: 2_000 }));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(1_999); });
expect(markRead).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
expect(markRead).toHaveBeenCalledTimes(2);
});
it("stops after bounded retries and recovers the retained cutoff on reconnect", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
markRead.mockRejectedValue(new ApiError("Unavailable", 503, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(2_000); });
expect(markRead).toHaveBeenCalledTimes(3);
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Couldn’t mark this chat read.");
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(markRead).toHaveBeenCalledTimes(3);
markRead.mockResolvedValue({});
await act(async () => { window.dispatchEvent(new Event("online")); });
expect(markRead).toHaveBeenCalledTimes(4);
expect(markRead).toHaveBeenLastCalledWith("chat-1", first);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it.each(["company", "human", "chat", "unmount"])("cancels a pending retry after %s changes", async kind => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
markRead.mockRejectedValue(new TypeError("Network unavailable"));
await render();
expect(markRead).toHaveBeenCalledTimes(1);
focused = false;
if (kind === "unmount") await act(async () => { root.render(null); });
else await render([first], kind === "company" ? "company-2" : "company-1", kind === "human" ? "human-2" : "human-1", kind === "chat" ? "chat-2" : "chat-1");
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(markRead).toHaveBeenCalledTimes(1);
});
it("resumes an exhausted cutoff when the chat becomes visible again", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
markRead.mockRejectedValue(new TypeError("Network unavailable"));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(2_000); });
expect(markRead).toHaveBeenCalledTimes(3);
markRead.mockResolvedValue({});
visible = false;
await act(async () => { document.dispatchEvent(new Event("visibilitychange")); });
expect(markRead).toHaveBeenCalledTimes(3);
visible = true;
await act(async () => { document.dispatchEvent(new Event("visibilitychange")); });
expect(markRead).toHaveBeenCalledTimes(4);
expect(markRead).toHaveBeenLastCalledWith("chat-1", first);
});
it("does not retry a denied read receipt", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
markRead.mockRejectedValue(new ApiError("Forbidden", 403, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(markRead).toHaveBeenCalledTimes(1);
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Couldn’t mark this chat read.");
});
it("discards an in-flight failure after the human changes", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let fail!: (cause: Error) => void;
markRead.mockImplementationOnce(() => new Promise<void>((_, reject) => { fail = reject; }));
await render();
focused = false; await render([first], "company-1", "human-2");
await act(async () => { fail(new TypeError("Network unavailable")); });
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(markRead).toHaveBeenCalledTimes(1);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
+122
View File
@@ -0,0 +1,122 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { isUuidLike } from "@paperclipai/shared";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
type Seen = { id: string; at: number };
const READ_RETRY_DELAYS = [500, 1_500];
const ChatReadContext = createContext<((message: Seen) => void) | null>(null);
export function AgentChatReadProvider({ issueId, companyId, userId, children }: {
issueId?: string; companyId: string; userId: string | null; children: ReactNode;
}) {
const client = useQueryClient();
const [error, setError] = useState(false);
const scope = useMemo(() => ({ active: true, pending: null as Seen | null, seenIds: new Set<string>(), sentAt: -Infinity,
busy: false, failures: 0, exhausted: false, retryTimer: null as ReturnType<typeof setTimeout> | null }), [issueId, companyId, userId]);
const flush = useCallback(async function flush(): Promise<void> {
if (!issueId || !scope.active || scope.busy || scope.retryTimer !== null || scope.exhausted) return;
scope.busy = true;
try {
while (scope.active && scope.pending) {
const next = scope.pending;
try {
await issuesApi.markRead(issueId, next.id);
} catch (cause) {
if (!scope.active) return;
const retryable = !(cause instanceof ApiError) || cause.status === 408 || cause.status === 429 || cause.status >= 500;
const delay = READ_RETRY_DELAYS[scope.failures];
if (retryable && delay !== undefined) {
scope.failures += 1;
scope.retryTimer = setTimeout(() => { scope.retryTimer = null; void flush(); },
Math.max(delay, cause instanceof ApiError ? cause.retryAfterMs ?? 0 : 0));
} else {
scope.exhausted = true;
setError(true);
}
return;
}
if (!scope.active) return;
if (scope.pending?.id === next.id) scope.pending = null;
scope.sentAt = Math.max(scope.sentAt, next.at);
scope.failures = 0;
setError(false);
void client.invalidateQueries({ queryKey: queryKeys.agentChats.list(companyId, userId) });
void client.invalidateQueries({ queryKey: ["agent-chat", companyId, userId] });
}
} finally { scope.busy = false; }
}, [issueId, companyId, userId, client, scope]);
useEffect(() => {
scope.active = true;
setError(false);
if (scope.pending) {
scope.exhausted = false;
scope.failures = 0;
queueMicrotask(() => { void flush(); });
}
return () => {
scope.active = false;
if (scope.retryTimer !== null) { clearTimeout(scope.retryTimer); scope.retryTimer = null; }
};
}, [scope, flush]);
useEffect(() => {
const retry = () => {
if (document.visibilityState !== "visible" || !scope.active || !scope.exhausted || !scope.pending) return;
scope.exhausted = false;
scope.failures = 0;
setError(false);
void flush();
};
window.addEventListener("online", retry);
window.addEventListener("focus", retry);
document.addEventListener("visibilitychange", retry);
return () => {
window.removeEventListener("online", retry);
window.removeEventListener("focus", retry);
document.removeEventListener("visibilitychange", retry);
};
}, [scope, flush]);
const seen = useCallback((message: Seen) => {
if (!issueId || !scope.active || scope.seenIds.has(message.id) || message.at < scope.sentAt) return;
scope.seenIds.add(message.id);
if (!scope.pending || message.at >= scope.pending.at) scope.pending = message;
if (scope.exhausted) { scope.exhausted = false; scope.failures = 0; setError(false); }
if (!scope.busy && scope.retryTimer === null) queueMicrotask(() => { void flush(); });
}, [issueId, scope, flush]);
return <ChatReadContext.Provider value={issueId ? seen : null}>
{error && <p role="alert" className="text-sm text-destructive">Couldn’t mark this chat read.</p>}
{children}
</ChatReadContext.Provider>;
}
export function useAgentChatRead(id: string, createdAt: string | undefined, enabled: boolean) {
const seen = useContext(ChatReadContext);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!seen || !enabled || !element || !isUuidLike(id) || !createdAt) return;
const at = new Date(createdAt).getTime();
if (!Number.isFinite(at)) return;
const viewport = element.closest<HTMLElement>(".task-chat-scroll-viewport");
const check = () => {
if (document.visibilityState !== "visible" || !document.hasFocus() || !viewport) return;
if (viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > 1) return;
const message = element.getBoundingClientRect(), bounds = viewport.getBoundingClientRect();
if (message.height > 0 && message.bottom <= bounds.bottom + 1 && message.bottom > bounds.top) seen({ id, at });
};
const observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(check, { root: viewport });
observer?.observe(element);
viewport?.addEventListener("scroll", check, { passive: true });
document.addEventListener("visibilitychange", check);
window.addEventListener("focus", check);
const frame = requestAnimationFrame(check);
return () => {
cancelAnimationFrame(frame); observer?.disconnect();
viewport?.removeEventListener("scroll", check);
document.removeEventListener("visibilitychange", check); window.removeEventListener("focus", check);
};
}, [seen, id, createdAt, enabled]);
return ref;
}
+8
View File
@@ -4,11 +4,19 @@ import {
documentDisplayTitle,
getAttachmentBackedWorkProductAttachmentIds,
isAgentAttachment,
isGeneralTaskDocumentKey,
selectAgentArtifactAttachments,
selectArtifactAttachments,
workProductHref,
} from "./issue-artifacts";
it.each(["plan", " Plan ", "artifact-review-output", "pipeline-case-body", "continuation-summary"])("keeps %s out of general document authoring", key => {
expect(isGeneralTaskDocumentKey(key)).toBe(false);
});
it("allows ordinary general document keys", () => {
expect(isGeneralTaskDocumentKey("notes")).toBe(true);
});
function makeAttachment(overrides: Partial<IssueAttachment> & { id: string }): IssueAttachment {
return {
companyId: "company-1",
+6 -1
View File
@@ -1,5 +1,10 @@
import type { IssueAttachment, IssueDocumentSummary, IssueWorkProduct } from "@paperclipai/shared";
import { getAttachmentArtifactWorkProductMetadata } from "@paperclipai/shared";
import { getAttachmentArtifactWorkProductMetadata, isArtifactReviewDocumentKey, isSystemIssueDocumentKey } from "@paperclipai/shared";
/** Documents authored here; Plans and file reviews retain their own workflows. */
export function isGeneralTaskDocumentKey(key: string): boolean {
return key.trim().toLowerCase() !== "plan" && !isArtifactReviewDocumentKey(key) && !isSystemIssueDocumentKey(key);
}
/**
* Selectors for the properties pane's Artifacts tab (PAP-491): which
+74 -1
View File
@@ -16,6 +16,7 @@ import type {
IssueTreeControlPreview,
IssueTreeHold,
IssueWorkProduct,
Project,
} from "@paperclipai/shared";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import type {
@@ -1567,6 +1568,44 @@ describe("IssueDetail", () => {
await waitForAssertion(() => expect(mockIssueChatThreadRender).toHaveBeenCalled());
});
it("supplies direct children and source-created tasks to the feed without promoting grandchildren", async () => {
const issue = createIssue();
const child = createIssue({ id: "child", parentId: issue.id, createdByAgentId: "agent-1" });
const grandchild = createIssue({ id: "grandchild", parentId: child.id, createdByAgentId: "agent-1" });
const followup = createIssue({ id: "followup", createdByAgentId: "agent-1" });
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.listAll.mockImplementation(async (_companyId, filters) => filters?.createdFromIssueId ? [child, followup] : [child, grandchild]);
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.createdTasks).toEqual([child, child, followup]));
const updated = { ...child, status: "done" };
await act(async () => { queryClient.setQueryData(queryKeys.issues.listByDescendantRoot(issue.companyId, issue.id), [updated, grandchild]); });
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.createdTasks[0]).toMatchObject({ id: "child", status: "done" }));
});
it("keeps the conversation readable when created tasks fail to load and offers a retry", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.listAll.mockRejectedValue(new Error("Task read unavailable"));
await act(async () => renderIssueView());
await waitForAssertion(() => expect(container.textContent).toContain("Could not load created tasks."));
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.initialHistoryError).toBe(false);
mockIssuesApi.listAll.mockResolvedValue([]);
const retry = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Retry")!;
await act(async () => retry.click());
await waitForAssertion(() => expect(container.textContent).not.toContain("Could not load created tasks."));
});
it("loads standalone follow-up cards in the task feed when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: false, enableClassicTaskInterface: false });
const issue = createIssue();
const followup = createIssue({ id: "standalone", parentId: null, createdByAgentId: "agent-1" });
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.listAll.mockImplementation(async (_companyId, filters) => filters?.createdFromIssueId ? [followup] : []);
await act(async () => renderIssueView());
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.createdTasks).toEqual([followup]));
expect(container.querySelector('[data-testid="task-chat-thread"]')).not.toBeNull();
expect(mockIssuesApi.listAll).toHaveBeenCalledWith(issue.companyId, { createdFromIssueId: issue.id, includeRoutineExecutions: true });
});
it("uses Coordinator task presentation and panel scope without changing the Classic preference", async () => {
coordinatorPreference.enabled = true;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
@@ -1579,6 +1618,14 @@ describe("IssueDetail", () => {
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-thread"]')).not.toBeNull());
expect(container.querySelector('[data-testid="issue-chat-thread"]')).toBeNull();
await waitForAssertion(() => expect(mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props).toMatchObject({ accountScope: "coordinator:user-1" }));
const editor = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props.documentEditor;
expect(editor).toMatchObject({ mentions: expect.any(Array), imageUploadHandler: expect.any(Function), onVote: expect.any(Function) });
mockIssuesApi.uploadAttachment.mockResolvedValue(createAttachment({ id: "document-image" }));
const image = new File(["image"], "document.png", { type: "image/png" });
let uploaded: string | undefined;
await act(async () => { uploaded = await editor.imageUploadHandler(image); });
expect(uploaded).toBe(createAttachment({ id: "document-image" }).contentPath);
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledWith(createIssue().companyId, createIssue().id, image);
coordinatorPreference.enabled = false;
await act(async () => renderIssueView());
await waitForAssertion(() => expect(container.querySelector('[data-testid="issue-chat-thread"]')).not.toBeNull());
@@ -1639,7 +1686,7 @@ describe("IssueDetail", () => {
});
await flushReact();
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockIssuesApi.markRead).toHaveBeenCalledWith(canonical.id);
expect(mockIssuesApi.markRead).not.toHaveBeenCalled();
const [breadcrumbs] = mockSetBreadcrumbs.mock.calls.at(-1)!;
expect(breadcrumbs[0].leading.type).toBe(AgentAvatar);
expect(breadcrumbs[0].leading.props).toMatchObject({ agent, size: 24 });
@@ -3123,6 +3170,32 @@ describe("IssueDetail", () => {
await flushReact();
});
it.each([false, true])("keeps the panel stable while projects are still loading (Classic: %s)", async classic => {
const pendingProjects = createDeferred<Project[]>();
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableClassicTaskInterface: classic });
mockIssuesApi.get.mockResolvedValue(createIssue());
mockProjectsApi.list.mockReturnValue(pendingProjects.promise);
const renderDetail = () => <QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>;
try {
await act(async () => root.render(renderDetail()));
await flushReact();
await flushReact();
const panelOpenCount = mockOpenPanel.mock.calls.length;
expect(panelOpenCount).toBeGreaterThan(0);
await act(async () => root.render(renderDetail()));
await flushReact();
expect(mockOpenPanel).toHaveBeenCalledTimes(panelOpenCount);
} finally {
const beforeCompletion = mockOpenPanel.mock.calls.length;
pendingProjects.resolve([]);
await waitForAssertion(() => expect(mockOpenPanel.mock.calls.length).toBeGreaterThan(beforeCompletion));
}
const settledCount = mockOpenPanel.mock.calls.length;
await act(async () => root.render(renderDetail()));
await flushReact();
expect(mockOpenPanel).toHaveBeenCalledTimes(settledCount);
});
it("does not load or render decision sections in the issue header", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
+56 -32
View File
@@ -1,6 +1,7 @@
import { useIssueIdBatches } from "@/hooks/useIssueIdBatches";
import { useConversationHistoryPages, type ConversationHistoryPage } from "@/hooks/useConversationHistoryPages";
import { taskChatHashAnchor } from "@/components/task-chat/scroll-navigation";
import { AgentChatReadProvider } from "@/hooks/useAgentChatRead";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { CoordinatorAgentSwitcher } from "@/components/CoordinatorAgentSwitcher";
import { TaskDecisionRecord } from "@/components/TaskDecisionRecord";
@@ -384,6 +385,7 @@ import {
type IssueRecentComments,
type IssueDocumentSummary,
type IssueWorkProduct,
type Project,
type IssueWorkMode,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
@@ -402,6 +404,7 @@ import {
// (loading/idle), which destabilizes downstream memos and panel keys that
// depend on it. Reusing one shared reference keeps those values stable.
const EMPTY_ISSUES: Issue[] = [];
const EMPTY_PROJECTS: Project[] = [];
type StopAndFinalizeRunError = Error & {
runCancelledBeforeStatusUpdateFailed?: boolean;
@@ -1280,6 +1283,7 @@ type IssueDetailChatTabProps = {
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
documents: IssueDocumentSummary[];
createdTasks: Issue[];
workProducts: IssueWorkProduct[];
attachments: IssueAttachment[];
hasOlderComments: boolean;
@@ -1437,6 +1441,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
locallyQueuedCommentRunIds,
interactions,
documents,
createdTasks,
workProducts,
attachments,
hasOlderComments,
@@ -1956,6 +1961,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
}
}
const projectedComments = commentsToProject.map((comment) => {
if (comment.metadata?.chatMessage) return { ...comment, runId: null };
const activityMeta = runMetaByCommentId.get(comment.id);
// Internal run finalization can persist a reply without a separate
// comment_added activity row. Its durable authoring run is stronger
@@ -2653,7 +2659,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
interruption={interruption}
retryPreparing={retryPreparing}
key={conversationMode ? draftKey : issueId}
{...(!classicTaskInterfaceEnabled ? { creationActivity: resolvedActivity } : {})}
{...(!classicTaskInterfaceEnabled ? { creationActivity: resolvedActivity, createdTasks } : {})}
onOpenSkill={onOpenSkill}
browsers={browsers}
onOpenBrowser={onOpenBrowser}
@@ -3678,7 +3684,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
createdFromIssueId: issue!.id,
includeRoutineExecutions: true,
}),
enabled: streamlinedTaskDetailEnabled && !!resolvedCompanyId && !!issue?.id && !tasksTab,
enabled: !!resolvedCompanyId && !!issue?.id && !issue.id.startsWith("chat:") && (
(taskChatShellEnabled && !conversation) || (streamlinedTaskDetailEnabled && !tasksTab)
),
});
const {
@@ -3738,7 +3746,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
});
// Bounded pool of recently-updated issues to back the `@task` reference picker.
// The picker filters this list client-side by identifier/title.
const { data: mentionIssues = [] } = useQuery({
const { data: mentionIssues = EMPTY_ISSUES } = useQuery({
queryKey: resolvedCompanyId
? queryKeys.issues.mentionPool(resolvedCompanyId)
: ["issues", "mention-pool", "pending"],
@@ -3760,7 +3768,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
queryFn: () => authApi.getSession(),
});
const { data: projects } = useQuery({
const { data: projects = EMPTY_PROJECTS } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
@@ -3812,7 +3820,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const fileViewerEnabled =
instanceExperimentalSettings?.enableExperimentalFileViewer === true;
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
projects,
companyId: selectedCompanyId,
userId: currentUserId,
});
@@ -3957,6 +3965,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
}, [issue?.id, rawChildIssues]);
const threadCreatedTasks = useMemo(() => [
...childIssues.filter((task) => task.parentId === issue?.id),
...(createdTasksQuery.data ?? EMPTY_ISSUES),
], [childIssues, createdTasksQuery.data, issue?.id]);
const resolvedTasksTab = useMemo(() => {
if (tasksTab) return tasksTab;
if (!streamlinedTaskDetailEnabled) return undefined;
@@ -3977,7 +3989,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
issueLinkState={resolvedIssueDetailState ?? location.state}
subtasks={childIssues}
createdTasks={createdTasks}
projects={projects ?? []}
projects={projects}
isLoading={createdTasksQuery.isLoading || childIssuesLoading}
hasError={hasError}
onRetry={() => {
@@ -6101,6 +6113,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
useEffect(() => {
if (!issueId || !issue?.id) return;
if (issue.conversationAgentId) return;
if (lastMarkedReadIssueIdRef.current === issue.id) return;
lastMarkedReadIssueIdRef.current = issue.id;
markIssueRead.mutate(issue.id);
@@ -6195,6 +6208,25 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
[openIssueGallery],
);
const taskDocumentEditor = useMemo<NonNullable<TaskSidePanelProps["documentEditor"]>>(() => ({
feedbackVotes,
feedbackDataSharingPreference,
feedbackTermsUrl: FEEDBACK_TERMS_URL,
mentions: mentionOptions,
externalReferences: externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined,
imageUploadHandler: async (file) => (await uploadAttachment.mutateAsync(file)).contentPath,
onVote: async (revisionId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_document_revision", targetId: revisionId, vote,
reason: options?.reason, allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
},
agentMap,
userProfileMap,
}), [feedbackVotes, feedbackDataSharingPreference, mentionOptions, externalObjectsState.isEnabled,
externalObjectsState.markdownReferences, uploadAttachment.mutateAsync, feedbackVoteMutation.mutateAsync, agentMap, userProfileMap]);
useLayoutEffect(() => {
if (!panelIssue || suppressPanelUntilPlan || (conversation && !conversation.issue) || (taskChatShellEnabled && isMobile)) {
closePanel();
@@ -6235,6 +6267,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
openPanel(
<IssueGalleryContext.Provider value={openIssueGallery}>
<TaskSidePanel
documentEditor={taskDocumentEditor}
pluginPanels={pluginPanels}
pluginPanelsLoading={pluginPanelsLoading}
openedPluginPanelKeys={openedPluginPanelKeys.current}
@@ -6294,6 +6327,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
externalObjectsState.refetch,
documentDeepLink,
taskChatShellEnabled,
taskDocumentEditor,
currentUserId,
coordinatorWorkspaceEnabled,
fileViewerEnabled,
@@ -8082,6 +8116,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
return (
<FileViewerProvider issueId={conversation && !conversation.issue ? "" : issue.id} enabled={fileViewerEnabled}>
<AgentChatReadProvider issueId={issue.conversationAgentId && issue.conversationUserId === (currentUserId ?? "local-board") && (!conversation || conversation.issue) ? issue.id : undefined} companyId={issue.companyId} userId={currentUserId}>
<TextAttachmentContext.Provider value={taskChatShellEnabled ? handleOpenTextAttachment : null}><IssueGalleryContext.Provider value={openIssueGallery}>
<div
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
@@ -8228,35 +8263,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
pane (Artifacts tab) — the center column belongs to the thread. */}
{taskChatShellEnabled ? null : (
<IssueDocumentsSection
{...taskDocumentEditor}
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
canManageDocumentLocks={Boolean(session?.user?.id)}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
mentions={mentionOptions}
externalReferences={
externalObjectsState.isEnabled
? externalObjectsState.markdownReferences
: undefined
}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onVote={async (revisionId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_document_revision",
targetId: revisionId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
extraActions={!hasAttachments ? attachmentUploadButton : null}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
)}
@@ -8462,7 +8473,17 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
onOpenSkill={handleOpenSkill}
browsers={browserQuery.data}
onOpenBrowser={(id) => { setOpenBrowserId(id); if (isMobile) setMobilePropsOpen(true); else openTaskSidePanel(); }}
threadHeader={<>{taskChatThreadHeader}<EmailTaskActivity key={issue.id} companyId={issue.companyId} issueId={issue.id} /></>}
threadHeader={<>{taskChatThreadHeader}<EmailTaskActivity key={issue.id} companyId={issue.companyId} issueId={issue.id} />
{(childIssuesError || createdTasksQuery.isError) && (
<div role="alert" className="flex items-center gap-2 px-4 text-sm text-destructive">
<span>Could not load created tasks.</span>
<Button variant="ghost" size="sm" onClick={() => {
void refetchChildIssues();
void createdTasksQuery.refetch();
}}>Retry</Button>
</div>
)}
</>}
issueBrief={
// Suppress the seeded-description bubble for the onboarding first
// task: its description is agent instructions, not something the
@@ -8564,6 +8585,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
documents={issue.documentSummaries ?? []}
createdTasks={threadCreatedTasks}
workProducts={workProducts ?? issue.workProducts ?? []}
attachments={attachments ?? []}
hasOlderComments={paginatedConversation && !historyExpanded
@@ -8860,6 +8882,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
<SheetTitle>Task side panel</SheetTitle>
</SheetHeader>
<TaskSidePanel
documentEditor={taskDocumentEditor}
pluginPanels={pluginPanels}
pluginPanelsLoading={pluginPanelsLoading}
openedPluginPanelKeys={openedPluginPanelKeys.current}
@@ -8991,6 +9014,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
<ScrollToBottom />
</div>
</IssueGalleryContext.Provider></TextAttachmentContext.Provider>
</AgentChatReadProvider>
</FileViewerProvider>
);
}
+74
View File
@@ -1014,6 +1014,80 @@ describe("New agent setup", () => {
expect(api.hire.mock.calls[0][1].adapterConfig.env[key]).toMatchObject({ type: "secret_ref", secretId: "org-secret-1" });
expect(container.querySelector('[aria-label="Thinking effort"]')).toBeNull();
});
it("offers native Kimi separately from the existing legacy Kimi under a qualified release fixture", async () => {
const profile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(entry => entry.value === "kimi") as { qualified: boolean };
const previousQualified = profile.qualified;
profile.qualified = true;
try {
const setupId = "00000000-0000-4000-8000-000000000094";
state.params = new URLSearchParams({ name: "Atlas", adapterType: "process", setupAgentId: setupId });
api.get.mockResolvedValue({ id: setupId, companyId: "company-1", name: "Atlas", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, metadata: { setupAdapterPending: true }, pauseReason: "setup", status: "paused" });
await renderCurrentParams();
await settle();
expect(document.querySelector('input[name="new-agent-adapter"][value="kimi_local"]')).not.toBeNull();
const native = document.querySelector<HTMLInputElement>('input[name="new-agent-adapter"][value="kimi_runtime"]');
expect(native).not.toBeNull();
await act(async () => native!.click());
await act(async () => [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === "Configure agent")!.click());
await settle();
expect(api.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "kimi" } }), "company-1");
expect(state.navigate).toHaveBeenLastCalledWith(expect.stringContaining("runnerProvider=kimi"), { replace: true });
} finally { profile.qualified = previousQualified; }
});
it("keeps an unqualified native Kimi declaration behind qualification", async () => {
const profile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(entry => entry.value === "kimi") as { qualified: boolean };
const previousQualified = profile.qualified;
try {
profile.qualified = false;
await render("paperclip_runner", "kimi");
await fill("KIMI_API_KEY", "kimi-test-key");
await fill("Model", "explicit-kimi-model");
await click("Finish setup");
expect(api.testEnvironment).not.toHaveBeenCalled();
expect(api.hire).not.toHaveBeenCalled();
expect(container.textContent).toContain("Kimi Code is not enabled for production");
} finally { profile.qualified = previousQualified; }
});
it("saves and reopens native Kimi with its exact model and one organization secret under a qualified release fixture", async () => {
const profile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(entry => entry.value === "kimi") as { qualified: boolean };
const previousQualified = profile.qualified;
profile.qualified = true;
try {
await render("paperclip_runner", "kimi");
expect(container.querySelector('[aria-label="Kimi API model name"]')).toBeNull();
await click("Run test");
expect(api.testEnvironment).not.toHaveBeenCalled();
expect(container.textContent).toContain("Enter the exact Kimi model ID");
await fill("Model", "explicit-unlisted-kimi-model");
await fill("KIMI_API_KEY", "kimi-test-key");
await click("Run test");
expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "paperclip_runner", expect.objectContaining({
adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "kimi", model: "explicit-unlisted-kimi-model" }),
testCredentials: { KIMI_API_KEY: "kimi-test-key" },
}));
await click("Save & exit");
const setupId = api.create.mock.calls[0][1].setupId;
expect(api.update.mock.calls.at(-1)![1].adapterConfig).toMatchObject({
provider: "acpx", acpxAgent: "kimi", model: "explicit-unlisted-kimi-model",
env: { KIMI_API_KEY: { type: "secret_ref", secretId: "org-secret-1" } },
});
await act(async () => root.render(null));
cache.clear();
state.params = new URLSearchParams({ name: "Atlas", adapterType: "paperclip_runner", runnerProvider: "kimi", setupAgentId: setupId });
await renderCurrentParams();
await settle();
expect((container.querySelector('[aria-label="Model"]') as HTMLInputElement).value).toBe("explicit-unlisted-kimi-model");
expect(container.querySelector('[aria-label="KIMI_API_KEY"]')?.getAttribute("placeholder")).toBe("Using saved key");
await click("Finish setup");
expect(api.hire).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "kimi", model: "explicit-unlisted-kimi-model" }),
}));
expect(secrets.create).toHaveBeenCalledTimes(1);
} finally { profile.qualified = previousQualified; }
});
it("defines a Kimi API model without overriding it with a CLI model alias", async () => {
await render("kimi_local");
await fill("KIMI_MODEL_API_KEY", "kimi-test-key");
+30
View File
@@ -24,6 +24,7 @@ vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: {
} }));
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listConnectionGrantsMock = vi.hoisted(() => vi.fn());
const getConnectionMock = vi.hoisted(() => vi.fn());
const getConnectionInstallsMock = vi.hoisted(() => vi.fn());
const connectAppMock = vi.hoisted(() => vi.fn());
@@ -82,6 +83,7 @@ vi.mock("@/api/tools", () => ({
listGallery: (companyId: string) => listGalleryMock(companyId),
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
getConnection: (id: string) => getConnectionMock(id),
getConnectionInstalls: (id: string) => getConnectionInstallsMock(id),
connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input),
@@ -2894,6 +2896,34 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
});
it("keeps Gmail read-only selected when Finish setup resumes a declined Workspace draft", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
const workspace = getAppStoreDefinition("google-workspace")!;
mockSearch.value = `source=google-workspace&resume=${connectionId}&oauth=denied`;
listGalleryMock.mockResolvedValue({ apps: [{ ...workspace, ownershipAvailability: { ...workspace.ownershipAvailability, platform_shared: true } }] });
listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-workspace", companyId: "company-1", status: "draft", metadata: { sourceTemplateKey: "google-workspace" } }] });
listConnectionsMock.mockResolvedValue({ connections: [{
...notionConnection(connectionId, "per_user", "draft"), companyId: "company-1", applicationId: "app-workspace",
config: { sourceTemplateKey: "google-workspace", connectionMethodKey: "paperclip-workspace", workspaceSetup: {
version: 1, companyId: "company-1", connectionId, actorType: "user", actorId: "me", grantKind: "user", subjectId: "me",
requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"],
} },
}] });
listConnectionGrantsMock.mockResolvedValue({ currentUserId: "me", grants: [] });
await render();
await vi.waitFor(() => expect(container.textContent).toContain("Try again"));
await act(async () => buttonByText("Try again")!.click());
await flushReact();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"] select')).not.toBeNull());
const dialog = document.querySelector('[role="dialog"]')!;
expect([...dialog.querySelectorAll('[role="switch"]')].map(item => item.getAttribute("aria-checked"))).toEqual(["true", "false", "false"]);
expect(dialog.querySelector("select")?.value).toBe("read");
await act(async () => buttonByText("Continue")!.click());
await flushReact();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true, workspaceServices: { gmail: "read" } });
});
it("resumes a hidden provider draft only after matching its company-visible application", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=clickup&resume=${connectionId}`;
@@ -37,6 +37,13 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { name: "01 · Agent list · Interactive" };
export const UnreadMessages: Story = {
name: "11 · Proactive message unread",
args: { unreadAgentIds: [sidebarAgents[0].id] },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole("img", { name: `Unread messages from ${sidebarAgents[0].name}` })).toBeVisible();
},
};
export const Search: Story = { name: "02 · Search by role", args: { initialSearch: "research" } };
export const NoResults: Story = { name: "03 · No matches", args: { initialSearch: "accountant" } };
export const Loading: Story = { name: "04 · Loading", args: { agents: [], availableAgents: [], loading: true } };
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model";
import { prepareProactiveMessageFixtures, ProactiveMessageReview } from "../../support/agent-chat/proactive-messages";
const message: TaskChatMessageItem = {
id: "digest", kind: "message", author: "agent", authorName: "Kodi",
text: "## Morning PR digest\n\nThree PRs need your review:\n\n- **#124** — Fix chat reconnect\n- **#127** — Improve task search\n- **#130** — Add release notes",
timestamp: "7:00 AM",
chatMessage: { sourceTaskId: "11111111-1111-4111-8111-111111111111", sourceTaskIdentifier: "PC-42",
sourceRunId: "22222222-2222-4222-8222-222222222222", contentHash: "0".repeat(64) },
};
const meta = {
id: "agent-chat-proactive-messages",
title: "Tasks and conversations/Agent conversations and project handoff/Proactive messages",
component: TaskChatBubble,
args: { item: message, animateEntry: false },
render: args => <div className="mx-auto max-w-2xl p-6"><TaskChatBubble {...args} /></div>,
parameters: { layout: "padded", initialEntries: ["/PAP/chats"], paperclip: {
capabilities: ["agent-chat/history", "agent-chat/discovery"], status: "current", implementation: "production", boundary: "fixtures",
notes: "Production Sidebar, CoordinatorSidebar, AgentConversationsSidebar, TaskChatBubble and read-receipt hook in a review-only gallery. Stateful API fixtures show unread-to-read and error transitions. The app navigation is currently expanded; the retired rail is not recreated. Server delivery, event refresh, account isolation, routine execution and real source-task navigation require the separate app/database checks.",
} },
beforeEach: ({ parameters }) => prepareProactiveMessageFixtures(parameters.readFailure === true, Number(parameters.transientReadFailures ?? 0)).cleanup,
} satisfies Meta<typeof TaskChatBubble>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllChangedUx: Story = {
name: "01 · All changed UX",
parameters: { layout: "fullscreen", options: { showPanel: false } },
render: () => <ProactiveMessageReview />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByRole("img", { name: "Unread messages from Kodi" })).toBeVisible();
await expect(canvas.getByRole("img", { name: "Unread messages from Mira" })).toBeVisible();
await expect(canvas.findAllByLabelText("2 unread chats")).resolves.toHaveLength(2);
await expect(canvas.getByRole("link", { name: "From PAP-42" })).toHaveAttribute("href", expect.stringContaining("/issues/PAP-42"));
},
};
export const ReadOnOpen: Story = {
...AllChangedUx,
name: "02 · Open chat marks the rendered message read",
parameters: { layout: "fullscreen", options: { showPanel: true } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole("img", { name: "Unread messages from Kodi" });
await userEvent.click(canvas.getByRole("button", { name: "Open Kodi chat" }));
await waitFor(() => expect(canvas.queryByRole("img", { name: "Unread messages from Kodi" })).not.toBeInTheDocument());
await expect(canvas.getByRole("img", { name: "Unread messages from Mira" })).toBeVisible();
await expect(canvas.findAllByLabelText("1 unread chats")).resolves.toHaveLength(2);
},
};
export const ReadAutomaticallyRecovers: Story = {
...AllChangedUx,
name: "03 · Read receipt recovers automatically",
parameters: { layout: "fullscreen", options: { showPanel: true }, transientReadFailures: 1 },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole("img", { name: "Unread messages from Kodi" });
await userEvent.click(canvas.getByRole("button", { name: "Open Kodi chat" }));
await waitFor(() => expect(canvas.queryByRole("img", { name: "Unread messages from Kodi" })).not.toBeInTheDocument(), { timeout: 5_000 });
await expect(canvas.queryByRole("alert")).not.toBeInTheDocument();
await expect(canvas.findAllByLabelText("1 unread chats")).resolves.toHaveLength(2);
},
};
export const ReadReceiptFailure: Story = {
...AllChangedUx,
name: "04 · Read receipt failed after retries",
parameters: { layout: "fullscreen", options: { showPanel: true }, readFailure: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole("img", { name: "Unread messages from Kodi" });
await userEvent.click(canvas.getByRole("button", { name: "Open Kodi chat" }));
await expect(await canvas.findByRole("alert", {}, { timeout: 5_000 })).toHaveTextContent("Couldn’t mark this chat read.");
await expect(canvas.getByRole("img", { name: "Unread messages from Kodi" })).toBeVisible();
await expect(canvas.findAllByLabelText("2 unread chats")).resolves.toHaveLength(2);
},
};
export const MobileReview: Story = { ...AllChangedUx, name: "05 · All changed UX · Mobile", globals: { viewport: { value: "mobile", isRotated: false } } };
export const LightReview: Story = { ...AllChangedUx, name: "06 · All changed UX · Light", globals: { theme: "light" } };
export const MorningDigest: Story = {
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole("link", { name: "From PC-42" })).toHaveAttribute("href", expect.stringContaining("/issues/PC-42"));
},
};
export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } };
export const Light: Story = { globals: { theme: "light" } };
@@ -36,6 +36,7 @@ const meta = {
beforeEach: ({ parameters }) => {
let doc = {
...storybookIssueDocuments[0],
...(parameters.generalDocument ? { key: "notes", title: "Release notes" } : {}),
body: "# Release plan\n\nRun the release checks.",
latestRevisionNumber: 2,
latestRevisionId: "revision-latest",
@@ -132,6 +133,18 @@ export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const Edit: Story = { args: { forceEditDocumentKey: "plan" } };
export const GeneralDocuments: Story = {
args: { generalDocumentsOnly: true },
parameters: { generalDocument: true },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("Run the release checks.");
await within(canvasElement).findByRole("button", { name: "New document" });
},
};
export const GeneralDocumentEdit: Story = {
args: { generalDocumentsOnly: true, forceEditDocumentKey: "notes" },
parameters: { generalDocument: true },
};
async function previousRevision(canvasElement: HTMLElement) {
await userEvent.click(
@@ -0,0 +1,79 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, IssueComment, IssueDocumentSummary } from "@paperclipai/shared";
import { TaskChatThread } from "@/components/TaskChatThread";
import { storybookAgents, storybookIssues } from "../../fixtures/paperclipData";
import { queryKeys } from "@/lib/queryKeys";
const parent = storybookIssues[0]!;
const at = (minute: number) => new Date(`2026-10-30T09:${String(minute).padStart(2, "0")}:00Z`);
const task: Issue = {
...parent, id: "inline-subtask", parentId: parent.id, identifier: "PAP-42",
title: "Fix incomplete search results when the task list has multiple pages",
status: "in_progress", createdByAgentId: "agent-1", createdAt: at(2),
};
const comments: IssueComment[] = [
{ id: "request", authorType: "user", authorUserId: "viewer", body: "Review the new findings and create a task for each repair that needs work.", createdAt: at(0) },
{ id: "progress", authorType: "agent", authorAgentId: "agent-1", body: "I found one actionable issue. I’ll create a subtask for the repair and save the batch report here.", createdAt: at(1) },
{ id: "response", authorType: "agent", authorAgentId: "agent-1", body: "Created one repair task. The batch report has the source evidence and selection details.", createdAt: at(4) },
].map((comment) => ({
...comment, authorType: comment.authorType as "user" | "agent",
authorAgentId: comment.authorAgentId ?? null, authorUserId: comment.authorUserId ?? null,
presentation: null, metadata: null, companyId: parent.companyId, issueId: parent.id, updatedAt: comment.createdAt,
}));
const report = {
id: "report", issueId: parent.id, companyId: parent.companyId, key: "batch-report", title: "Batch report",
format: "markdown", latestRevisionNumber: 1, latestRevisionId: "report-1", createdAt: at(3), updatedAt: at(3),
} as IssueDocumentSummary;
function TaskFeed({ multiple = false }: { multiple?: boolean }) {
const [client] = useState(() => {
const cache = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
cache.setQueryData([...queryKeys.issues.documents(parent.id), "plan"], null);
return cache;
});
return <QueryClientProvider client={client}>
<div className="mx-auto flex min-h-dvh w-full max-w-3xl flex-col overflow-hidden bg-background px-4 text-foreground">
<TaskChatThread
issueId={parent.id} companyId={parent.companyId} issueStatus="done" conversationMode={false}
comments={comments} documents={[report]}
createdTasks={multiple ? [task, { ...task, id: "second-subtask", identifier: "PAP-43", title: "Verify the repair against the source evidence", status: "blocked", createdAt: at(2) }] : [task]}
agentMap={new Map([["agent-1", { ...storybookAgents[0]!, id: "agent-1", name: "Researcher" }]])}
currentUserId="viewer" enableLiveTranscriptPolling={false} onAdd={async () => {}} showComposer={false}
threadHeader={<header className="border-b border-border p-4"><p className="text-xs text-muted-foreground">Task feed</p><h1 className="text-lg font-semibold">Daily findings and repair tasks</h1></header>}
/>
</div>
</QueryClientProvider>;
}
const meta = {
id: "task-created-inline",
title: "Tasks and conversations/Delegation, dependencies, and signoff/Created task cards",
component: TaskFeed,
parameters: {
layout: "fullscreen", initialEntries: ["/PAP/issues/parent"],
paperclip: {
capabilities: ["task-coordination/delegation", "tasks/discussion"],
implementation: "production", boundary: "fixtures",
notes: "Production TaskChatThread assembles created task cards beside a saved document. Task reads and status are fixtures; no agent runs or task writes occur.",
},
},
globals: { theme: "light" },
} satisfies Meta<typeof TaskFeed>;
export default meta;
type Story = StoryObj<typeof meta>;
export const InlineSubtask: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const card = await canvas.findByTestId("task-chat-created-task");
await expect(card).toHaveTextContent("Subtask created");
await expect(card).toHaveTextContent("In progress");
await expect(card).toHaveAttribute("href", "/PAP/issues/PAP-42");
await expect(canvas.getAllByTestId("task-chat-created-task")).toHaveLength(1);
},
};
export const MultipleSubtasks: Story = { args: { multiple: true } };
export const Dark: Story = { globals: { theme: "dark" } };
export const Mobile: Story = { args: { multiple: true }, globals: { viewport: { value: "mobile", isRotated: false } } };
@@ -0,0 +1,119 @@
import { useState } from "react";
import { instanceExperimentalSettingsSchema, type Issue } from "@paperclipai/shared";
import { Sidebar } from "@/components/Sidebar";
import { CoordinatorSidebar } from "@/components/CoordinatorSidebar";
import { AgentConversationsSidebar } from "@/components/AgentConversationsSidebar";
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model";
import { Button } from "@/components/ui/button";
import { AgentChatReadProvider } from "@/hooks/useAgentChatRead";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { useLocation, useNavigate } from "@/lib/router";
import { storybookAgents, storybookIssues } from "../../fixtures/paperclipData";
import { prepareFixtureApi } from "../capability-gaps/api";
export const proactiveCompanyId = "company-storybook";
export const proactiveChatId = "33333333-3333-4333-8333-333333333333";
export const proactiveMessage: TaskChatMessageItem = {
id: "44444444-4444-4444-8444-444444444444",
kind: "message",
author: "agent",
authorName: "Kodi",
text: "## Morning PR digest\n\nThree PRs need your review:\n\n- **#124** — Fix chat reconnect\n- **#127** — Improve task search\n- **#130** — Add release notes",
timestamp: "7:00 AM",
createdAtIso: "2026-10-30T07:00:00.000Z",
chatMessage: {
sourceTaskId: "11111111-1111-4111-8111-111111111111",
sourceTaskIdentifier: "PAP-42",
sourceRunId: "22222222-2222-4222-8222-222222222222",
contentHash: "0".repeat(64),
},
};
const agents = [
{ ...storybookAgents[0], name: "Kodi", urlKey: "kodi", status: "idle" as const, title: "Coding and pull requests" },
{ ...storybookAgents[1], name: "Mira", urlKey: "mira", status: "idle" as const, title: "Research and planning" },
];
export function prepareProactiveMessageFixtures(readFailure = false, transientReadFailures = 0) {
const unread = new Set(agents.map(agent => agent.id));
let remainingReadFailures = transientReadFailures;
const chats = () => agents.map((agent, index): Issue => ({
...storybookIssues[0],
id: index === 0 ? proactiveChatId : "55555555-5555-4555-8555-555555555555",
companyId: proactiveCompanyId,
title: `Chat with ${agent.name}`,
identifier: `PAP-${index + 100}`,
assigneeAgentId: agent.id,
conversationAgentId: agent.id,
conversationUserId: "user-board",
conversationState: "waiting",
status: "in_review",
projectId: null,
executionRunId: null,
updatedAt: new Date(index === 0 ? "2026-10-30T07:00:00Z" : "2026-10-30T06:45:00Z"),
isUnreadForMe: unread.has(agent.id),
}));
return prepareFixtureApi([
{ path: "/api/instance/settings/experimental", read: () => instanceExperimentalSettingsSchema.parse({ enableAgentChat: true }) },
{ path: "/api/workspace-preferences/me", read: () => ({ userId: "user-board", coordinatorWorkspaceEnabled: false }) },
{ path: `/api/companies/${proactiveCompanyId}/agents`, read: () => agents },
{ path: `/api/companies/${proactiveCompanyId}/chats`, read: chats },
{ path: `/api/companies/${proactiveCompanyId}/projects`, read: () => [] },
{ path: `/api/companies/${proactiveCompanyId}/live-runs`, read: () => [] },
{ path: `/api/companies/${proactiveCompanyId}/attention`, read: () => ({ companyId: proactiveCompanyId, totalCount: 0, deskBadgeCount: 0, items: [], nextCursor: null }) },
{ path: `/api/companies/${proactiveCompanyId}/resource-memberships/me`, read: () => ({ projectMemberships: {}, agentMemberships: {}, starredProjectIds: [], starredAgentIds: [], starredDocumentIds: [], updatedAt: null }) },
{ path: "/api/cli-auth/me", read: () => ({ userId: "user-board", isInstanceAdmin: false, companyIds: [proactiveCompanyId], source: "authenticated", keyId: null, memberships: [{ companyId: proactiveCompanyId, membershipRole: "owner", status: "active" }] }) },
{ path: `/api/issues/${proactiveChatId}/read`, method: "POST", read: ({ body }) => {
if (body.throughCommentId !== proactiveMessage.id) return Response.json({ error: "Expected the rendered message cutoff." }, { status: 400 });
if (readFailure || remainingReadFailures > 0) {
remainingReadFailures = Math.max(0, remainingReadFailures - 1);
return Response.json({ error: "Read receipt unavailable." }, { status: 503 });
}
unread.delete(agents[0].id);
return { issueId: proactiveChatId, userId: "user-board", lastReadAt: proactiveMessage.createdAtIso };
} },
]);
}
export function ProactiveMessageReview() {
const navigate = useNavigate();
const { pathname } = useLocation();
const open = pathname.endsWith("/chats/kodi");
const [opened, setOpened] = useState(false);
return <main className="paperclip-story">
<div className="paperclip-story__inner space-y-6">
<header className="space-y-2">
<p className="paperclip-story__label">Proactive agent messages · changed UX</p>
<h1 className="text-2xl font-semibold">Kodi’s morning digest is waiting in chat</h1>
<p className="max-w-3xl text-sm text-muted-foreground">Both Chat navigation variants count unread chats. Each agent has its own unread dot. Open Kodi at the latest message to clear its dot and reduce the count; Mira stays unread.</p>
</header>
<div className="grid gap-4 md:grid-cols-3">
<section aria-label="Classic navigation" className="paperclip-story__frame overflow-hidden">
<h2 className="border-b border-border px-4 py-3 text-sm font-medium">Classic navigation</h2>
<div className="h-80 overflow-auto"><PluginLauncherProvider><Sidebar /></PluginLauncherProvider></div>
</section>
<section aria-label="Coordinator navigation" className="paperclip-story__frame overflow-hidden">
<h2 className="border-b border-border px-4 py-3 text-sm font-medium">Coordinator navigation</h2>
<div className="coordinator-workspace h-80 overflow-auto"><CoordinatorSidebar /></div>
</section>
<section aria-label="Agent unread messages" className="paperclip-story__frame overflow-hidden">
<h2 className="border-b border-border px-4 py-3 text-sm font-medium">Unread by agent</h2>
<div className="h-80"><AgentConversationsSidebar /></div>
</section>
</div>
<section aria-label="Kodi proactive digest" className="paperclip-story__frame overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3">
<div><h2 className="text-sm font-medium">Chat with Kodi</h2><p className="text-xs text-muted-foreground">A normal agent message with a link to its source task</p></div>
<Button variant="outline" onClick={() => { setOpened(true); navigate("/chats/kodi"); }}>Open Kodi chat</Button>
</div>
<AgentChatReadProvider issueId={open && opened ? proactiveChatId : undefined} companyId={proactiveCompanyId} userId="user-board">
<div className="task-chat-scroll-viewport h-96 overflow-auto p-6" tabIndex={0} aria-label="Kodi latest messages">
<div className="mx-auto max-w-3xl"><TaskChatBubble item={proactiveMessage} animateEntry={false} /></div>
</div>
</AgentChatReadProvider>
</section>
<p className="text-xs text-muted-foreground">Production navigation, agent list, message bubble and read-receipt hook with isolated API fixtures. Server delivery, live events, account isolation and routine execution are verified by separate app tests.</p>
</div>
</main>;
}