mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
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:
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) => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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"; })}
|
||||
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in new issue
Block a user