mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
Merge master and reconcile skill preparation optimizations
Preserve installed-selection reuse and bundled relocation together with metadata-only package preparation. Retain both cleanup notifications, use the hardened upstream editor test, and regenerate unchanged skill SQL as migration 0414. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
867c00673b
579 files changed
+106238
-69119
No files matched your search
+6
-2
@@ -1,3 +1,4 @@
|
||||
import { useCoordinatorWorkspaceEnabled } from "./hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { AgentChats } from "./pages/AgentChats";
|
||||
import { lazy, Suspense, useEffect, type ReactNode } from "react";
|
||||
import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
|
||||
@@ -763,7 +764,10 @@ function NoCompaniesStartPage() {
|
||||
|
||||
export function App() {
|
||||
const { pathname } = useLocation();
|
||||
const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } = useStreamlinedUiEnabled();
|
||||
const { enabled: classicStreamlinedUiEnabled, loaded: classicStreamlinedUiLoaded } = useStreamlinedUiEnabled();
|
||||
const { enabled: coordinatorWorkspaceEnabled, loaded: coordinatorWorkspaceLoaded } = useCoordinatorWorkspaceEnabled();
|
||||
const streamlinedUiEnabled = classicStreamlinedUiEnabled || coordinatorWorkspaceEnabled;
|
||||
const streamlinedUiLoaded = classicStreamlinedUiLoaded && coordinatorWorkspaceLoaded;
|
||||
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
|
||||
useEffect(() => {
|
||||
// Download the requested collection while access/company checks run. Detail
|
||||
@@ -798,7 +802,7 @@ export function App() {
|
||||
<Route path="ux-lab/cross-issue-collaboration" element={<CrossIssueCollaborationUxLab />} />
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route element={<ShellConfigurationGate loaded={streamlinedUiLoaded} />}>
|
||||
<Route element={<ShellConfigurationGate loaded={streamlinedUiLoaded} allowPendingTaskDetail={classicStreamlinedUiLoaded && !coordinatorWorkspaceLoaded} />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="instance" element={<LegacySettingsRedirect />} />
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { AttentionFeed, AttentionFeedQuery } from "@paperclipai/shared";
|
||||
import type { AttentionFeed, AttentionFeedQuery, DecisionHistoryPage, DecisionHistoryQuery } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const attentionApi = {
|
||||
history: (companyId: string, options: DecisionHistoryQuery = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(options)) if (value !== undefined && value !== "") params.set(key, String(value));
|
||||
return api.get<DecisionHistoryPage>(`/companies/${companyId}/attention/history?${params.toString()}`);
|
||||
},
|
||||
/**
|
||||
* Fetch the ranked Decisions attention feed for a company. The server
|
||||
* unions every attention source (approvals, interactions, recovery, reviews,
|
||||
|
||||
@@ -732,6 +732,8 @@ export const issuesApi = {
|
||||
api.get<DocumentRevision[]>(
|
||||
`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`,
|
||||
),
|
||||
getDocumentRevision: (id: string, key: string, revisionId: string) =>
|
||||
api.get<DocumentRevision>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}`),
|
||||
restoreDocumentRevision: (id: string, key: string, revisionId: string) =>
|
||||
api.post<IssueDocument>(
|
||||
`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { UpdateWorkspacePreference, WorkspacePreference } from "@paperclipai/shared";
|
||||
import { api, detachInflightGet } from "./client";
|
||||
|
||||
export const workspacePreferencesApi = {
|
||||
get: () => {
|
||||
const path = "/workspace-preferences/me";
|
||||
// React Query keys include the account; path-only coalescing must not
|
||||
// deliver a previous session's still-running request to the next account.
|
||||
detachInflightGet(path);
|
||||
return api.get<WorkspacePreference>(path, { cache: "no-store" });
|
||||
},
|
||||
update: (companyId: string, data: UpdateWorkspacePreference) =>
|
||||
api.put<WorkspacePreference>(`/companies/${companyId}/workspace-preferences/me`, data),
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { pendingRequestConfirmationInteraction } from "../fixtures/issueThreadInteractionFixtures";
|
||||
import { pendingRequestConfirmationInteraction, pendingRequestItemVerdictsInteraction } from "../fixtures/issueThreadInteractionFixtures";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
|
||||
@@ -17,7 +17,7 @@ vi.mock("@/lib/router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: { listInteractions: vi.fn(), acceptInteraction: vi.fn() },
|
||||
issuesApi: { listInteractions: vi.fn(), acceptInteraction: vi.fn(), submitInteractionVerdicts: vi.fn() },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
@@ -25,6 +25,28 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes the canonical pending status back after a partial item review", async () => {
|
||||
const interaction = pendingRequestItemVerdictsInteraction;
|
||||
const partial = { ...interaction, status: "pending" as const, result: { version: 1 as const, outcome: "resolved" as const,
|
||||
complete: false, items: [{ id: interaction.payload.items[0]!.id, verdict: "approve" as const, resolvedAt: new Date().toISOString() }] } };
|
||||
vi.mocked(issuesApi.listInteractions).mockResolvedValue([interaction]);
|
||||
vi.mocked(issuesApi.submitInteractionVerdicts).mockResolvedValue(partial);
|
||||
const onResolved = vi.fn();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const host = document.createElement("div"); document.body.append(host); const root = createRoot(host);
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><ThemeProvider>
|
||||
<AttentionInteractionResolver companyId={interaction.companyId} issueId={interaction.issueId} interactionId={interaction.id} onResolved={onResolved} />
|
||||
</ThemeProvider></TooltipProvider></QueryClientProvider>));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('button[data-verdict="approve"]')!.click());
|
||||
const apply = [...host.querySelectorAll("button")].find(button => button.textContent?.startsWith("Apply"));
|
||||
expect(apply).toBeTruthy();
|
||||
await act(async () => apply!.click());
|
||||
expect(onResolved).toHaveBeenCalledWith(partial);
|
||||
} finally { await act(async () => root.unmount()); host.remove(); client.clear(); }
|
||||
});
|
||||
|
||||
it("refreshes a preparing approval automatically, then stops polling without accepting it", async () => {
|
||||
vi.useFakeTimers();
|
||||
const interaction = pendingRequestConfirmationInteraction;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type RequestItemVerdictValue,
|
||||
type SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import { DecisionCardFooter } from "./DecisionCardFooter";
|
||||
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||||
|
||||
interface AttentionInteractionResolverProps {
|
||||
@@ -25,8 +26,10 @@ interface AttentionInteractionResolverProps {
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
/** Called after a resolution so the parent can refresh the feed. */
|
||||
onResolved?: () => void;
|
||||
footerSlot?: ReactNode;
|
||||
presentation?: "default" | "coordinator-decision";
|
||||
/** Successful updates include partial item reviews; inspect status before settling the whole card. */
|
||||
onResolved?: (interaction?: IssueThreadInteraction) => void;
|
||||
}
|
||||
|
||||
export function replaceResolvedInteraction(
|
||||
@@ -54,6 +57,8 @@ export function AttentionInteractionResolver({
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
presentation,
|
||||
footerSlot,
|
||||
onResolved,
|
||||
}: AttentionInteractionResolverProps) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -83,7 +88,9 @@ export function AttentionInteractionResolver({
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
onResolved?.();
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "pending-questions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "decision-receipts"] });
|
||||
onResolved?.(resolvedInteraction);
|
||||
};
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
@@ -129,23 +136,32 @@ export function AttentionInteractionResolver({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading decision…
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading decision…
|
||||
</div>
|
||||
<DecisionCardFooter leading={footerSlot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !interaction) {
|
||||
return (
|
||||
<p className="py-3 text-xs text-muted-foreground">
|
||||
This decision is no longer available — it may have been resolved elsewhere.
|
||||
</p>
|
||||
<div className="space-y-3 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This decision is no longer available — it may have been resolved elsewhere.
|
||||
</p>
|
||||
<DecisionCardFooter leading={footerSlot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
presentationVariant={presentation}
|
||||
footerSlot={footerSlot}
|
||||
primaryActionOnRight={presentation === "coordinator-decision"}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Fragment, useMemo, type ReactNode } from "react";
|
||||
import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useCoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
|
||||
|
||||
type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null };
|
||||
|
||||
@@ -53,6 +54,7 @@ function GlobalToolbar({
|
||||
}
|
||||
|
||||
export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
|
||||
const coordinatorEnabled = useCoordinatorPresentation();
|
||||
const {
|
||||
breadcrumbs,
|
||||
breadcrumbToolbar,
|
||||
@@ -209,6 +211,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
{coordinatorEnabled && breadcrumbs[0].trailing ? (
|
||||
<span className="ml-2 flex shrink-0 items-center">{breadcrumbs[0].trailing}</span>
|
||||
) : null}
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { AgentChatPicker } from "./AgentChatPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
|
||||
/** Switching opens the existing conversation or its unsaved draft view.
|
||||
* The production chat controller persists it only when the user sends. */
|
||||
export function CoordinatorAgentSwitcher({ agent }: { agent: Agent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { agents, chats } = useAgentChatNavigation();
|
||||
const navigate = useNavigate();
|
||||
return <>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Switch agent. Currently ${agent.name}`} title="Switch agent" onClick={() => setOpen(true)}><ChevronDown className="size-4" /></Button>
|
||||
<AgentChatPicker open={open} onOpenChange={setOpen} agents={(agents.data ?? []).filter(item => item.status !== "terminated")}
|
||||
loading={agents.isPending} error={agents.error} onRetry={() => { void agents.refetch(); }}
|
||||
existingChatAgentIds={(chats.data ?? []).flatMap(chat => chat.conversationAgentId ? [chat.conversationAgentId] : [])}
|
||||
onSelect={next => { navigate(`/chats/${encodeURIComponent(agentRouteRef(next))}`); }} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, type ReactNode } 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 { AttentionFeed, AttentionItem } from "@paperclipai/shared";
|
||||
import { CoordinatorSidebar } from "./CoordinatorSidebar";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const fixture = vi.hoisted(() => ({ attention: vi.fn(), collapsed: false, userId: "reviewer" }));
|
||||
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
|
||||
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: [] }, session: { isSuccess: true },
|
||||
}) }));
|
||||
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: [] }) }));
|
||||
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) }));
|
||||
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ collapsed: fixture.collapsed, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }) }));
|
||||
vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention } }));
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
|
||||
vi.mock("./SidebarCompanyMenu", () => ({ SidebarCompanyMenu: () => null }));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ pathname: "/COO/decisions" }),
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a>,
|
||||
NavLink: ({ to, children, className, state: _state, end: _end, ...props }: {
|
||||
to: string; children: ReactNode; className?: string | ((state: { isActive: boolean }) => string); state?: unknown; end?: boolean;
|
||||
}) => <a href={to} className={typeof className === "function" ? className({ isActive: false }) : className} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
const pending = (id: string) => ({ id, sourceKind: "issue_thread_interaction", subject: { id, kind: "interaction", status: "pending" },
|
||||
createdAt: "2026-10-20T12:00:00Z", activityAt: "2026-10-20T12:00:00Z" }) as AttentionItem;
|
||||
function feed(items: AttentionItem[]): AttentionFeed {
|
||||
return { companyId: "company", generatedAt: "2026-10-27T12:00:00Z", totalCount: items.length, deskBadgeCount: 0,
|
||||
nextCursor: null, countsBySourceKind: {} as AttentionFeed["countsBySourceKind"], items };
|
||||
}
|
||||
|
||||
let root: Root, container: HTMLDivElement, client: QueryClient;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer";
|
||||
fixture.attention.mockResolvedValue(feed([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", () => {
|
||||
const pinnedPages = () => container.querySelector('[aria-label="Pinned pages"]');
|
||||
async function click(label: string) {
|
||||
const button = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
|
||||
expect(button).not.toBeNull();
|
||||
await act(async () => button!.click());
|
||||
await flush();
|
||||
}
|
||||
|
||||
it("pins from More without navigating, restores after remount, and unpins from the sidebar", async () => {
|
||||
await renderSidebar();
|
||||
expect(pinnedPages()).toBeNull();
|
||||
await click("More workspace pages");
|
||||
expect(document.activeElement).toBe(document.querySelector('[data-slot="popover-content"]'));
|
||||
await click("Pin Projects");
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(pinnedPages()?.querySelector('a[href="/projects"]')?.textContent).toBe("Projects");
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await renderSidebar();
|
||||
expect(pinnedPages()?.querySelector('a[href="/projects"]')).not.toBeNull();
|
||||
await click("Unpin Projects");
|
||||
expect(pinnedPages()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps pins personal and only renders known destinations", async () => {
|
||||
localStorage.setItem("paperclip.coordinator.pinned-nav.v1:reviewer", JSON.stringify(["/projects", "/unknown", "https://example.com"]));
|
||||
await renderSidebar();
|
||||
expect(pinnedPages()?.querySelectorAll("a")).toHaveLength(1);
|
||||
fixture.userId = "other-user";
|
||||
await renderSidebar();
|
||||
expect(pinnedPages()).toBeNull();
|
||||
fixture.userId = "reviewer";
|
||||
await renderSidebar();
|
||||
expect(pinnedPages()?.textContent).toContain("Projects");
|
||||
});
|
||||
|
||||
it("reflects pins changed in another browser tab", async () => {
|
||||
await renderSidebar();
|
||||
const key = "paperclip.coordinator.pinned-nav.v1:reviewer";
|
||||
await act(async () => {
|
||||
localStorage.setItem(key, JSON.stringify(["/agents/all"]));
|
||||
window.dispatchEvent(new StorageEvent("storage", { key }));
|
||||
});
|
||||
expect(pinnedPages()?.querySelector('a[href="/agents/all"]')?.textContent).toBe("Agents");
|
||||
});
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
|
||||
async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); }
|
||||
async function renderSidebar() {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><CoordinatorSidebar /></TooltipProvider></QueryClientProvider>));
|
||||
await flush();
|
||||
}
|
||||
const decisionLink = () => container.querySelector('a[href="/decisions"]')!;
|
||||
|
||||
describe("Coordinator Needs You badge", () => {
|
||||
it("counts every canonical outstanding decision even when none arrived today", async () => {
|
||||
// Personal page filters and completed receipts are separate cache entries.
|
||||
client.setQueryData([...coordinatorAttentionQueryKey("company"), { search: "no match" }], feed([]));
|
||||
client.setQueryData([...queryKeys.attention("company"), "history", "recent"], { items: [{ id: "answered-question" }] });
|
||||
await renderSidebar();
|
||||
expect(fixture.attention).toHaveBeenCalledWith("company", { all: true });
|
||||
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
it("updates after canonical attention refreshes and removes the badge once every decision is resolved", async () => {
|
||||
await renderSidebar();
|
||||
fixture.attention.mockResolvedValue(feed([pending("review")]));
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); });
|
||||
await flush();
|
||||
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("1");
|
||||
fixture.attention.mockResolvedValue(feed([]));
|
||||
client.setQueryData([...queryKeys.attention("company"), "history", "recent"], { items: [{ id: "answered-question" }, { id: "accepted-review" }] });
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); });
|
||||
await flush();
|
||||
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
|
||||
expect(decisionLink().textContent).toBe("Needs You");
|
||||
});
|
||||
|
||||
it("announces the full outstanding count when the sidebar is collapsed", async () => {
|
||||
fixture.collapsed = true;
|
||||
await renderSidebar();
|
||||
expect(decisionLink().getAttribute("aria-label")).toBe("Needs You, 2 decisions");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Inbox, Bell, Boxes, ChevronRight, CircleCheck, FolderOpen, History, LayoutDashboard, MessageCircle, MoreHorizontal, Network, Pin, PinOff, Plus, Repeat, Search, Settings, Unplug } from "lucide-react";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import { attentionApi } from "@/api/attention";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
|
||||
import { useRecentTasks } from "@/hooks/useRecentTasks";
|
||||
import { usePinnedNavigation } from "@/hooks/usePinnedNavigation";
|
||||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Link, useLocation } from "@/lib/router";
|
||||
import { agentRouteRef, cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { TruncatedTextMarqueeProvider } from "./TruncatedText";
|
||||
import { useScrollbarWhileScrolling } from "./side-panel/use-scrollbar-while-scrolling";
|
||||
import { Flyout, FlyoutContent, FlyoutTrigger, FlyoutAppearanceProvider } from "./ui/flyout";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
const moreDestinations = [
|
||||
{ label: "Search", to: "/search", icon: Search },
|
||||
{ label: "Inbox", to: "/inbox", icon: Inbox },
|
||||
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
||||
{ label: "Projects", to: "/projects", icon: FolderOpen },
|
||||
{ label: "Routines", to: "/routines", icon: Repeat },
|
||||
{ label: "Artifacts", to: "/artifacts", icon: Boxes },
|
||||
{ label: "Agents", to: "/agents/all", icon: Network },
|
||||
{ label: "Skills", to: "/skills", icon: Boxes },
|
||||
{ label: "Connectors", to: "/apps", icon: Unplug },
|
||||
{ label: "Audit", to: "/activity", icon: History },
|
||||
{ label: "Settings", to: "/settings", icon: Settings },
|
||||
] as const;
|
||||
|
||||
function ProjectRecent({ project }: { project: Project }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { data: tasks, isPending, error } = useQuery({
|
||||
queryKey: [...queryKeys.issues.listByProject(project.companyId, project.id), "coordinator-recents"],
|
||||
queryFn: () => issuesApi.list(project.companyId, { projectId: project.id, limit: 12, sortField: "updated", sortDir: "desc" }),
|
||||
enabled: expanded,
|
||||
});
|
||||
return <div>
|
||||
<div className="group/coordinator-recent relative">
|
||||
<SidebarNavItem to={`/projects/${projectRouteRef(project)}`} label={project.name} icon={FolderOpen} className="design-sidebar-item pr-9" />
|
||||
<button type="button" aria-label={`${expanded ? "Collapse" : "Expand"} ${project.name}`} aria-expanded={expanded}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring" onClick={() => setExpanded(!expanded)}>
|
||||
<ChevronRight className={cn("size-3.5 transition-transform", expanded && "rotate-90")} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded && <div className="ml-5 border-l border-border pl-1">
|
||||
{isPending ? <p className="px-3 py-2 text-xs text-muted-foreground" role="status">Loading tasks…</p> : error ? <p className="px-3 py-2 text-xs text-muted-foreground">Couldn’t load tasks.</p> : tasks?.length ? tasks.map(task =>
|
||||
<SidebarNavItem className="design-sidebar-item" key={task.id} to={`/issues/${task.identifier ?? task.id}`} label={task.title} iconNode={<StatusIcon status={task.status} />} />)
|
||||
: <p className="px-3 py-2 text-xs text-muted-foreground">No tasks yet.</p>}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** The navigation is a view of the existing records; it never creates a chat
|
||||
* or task just because someone visits or switches agents. */
|
||||
export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
|
||||
const { companyId, userId, agents, chats, session } = useAgentChatNavigation();
|
||||
const pinnedNavigation = usePinnedNavigation(userId);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreContentRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedDestinations = pinnedNavigation.pins.flatMap(to => moreDestinations.filter(item => item.to === to));
|
||||
const { entries: recentTasks } = useRecentTasks({ companyId, userId });
|
||||
const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const location = useLocation();
|
||||
const handleRecentScroll = useScrollbarWhileScrolling();
|
||||
const { data: attention } = useQuery({ queryKey: coordinatorAttentionQueryKey(companyId!), queryFn: () => attentionApi.list(companyId!, { all: true }), enabled: !!companyId });
|
||||
const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!), queryFn: () => projectsApi.list(companyId!), enabled: !!companyId });
|
||||
// Only project IDs and visit times are kept in memory. Labels and task lists
|
||||
// always come from the authorized API, scoped to the current person/company.
|
||||
const scope = `${companyId}:${userId}`;
|
||||
const [projectVisits, setProjectVisits] = useState<{ scope: string; entries: Record<string, number> }>({ scope, entries: {} });
|
||||
useEffect(() => {
|
||||
const ref = location.pathname.match(/\/projects\/([^/]+)/)?.[1];
|
||||
const project = projects?.find(item => item.id === ref || projectRouteRef(item) === ref);
|
||||
if (!project) return;
|
||||
setProjectVisits(previous => ({ scope, entries: { ...(previous.scope === scope ? previous.entries : {}), [project.id]: Date.now() } }));
|
||||
}, [location.pathname, projects, scope]);
|
||||
const recents = useMemo(() => {
|
||||
const roster = new Map((agents.data ?? []).map(agent => [agent.id, agent]));
|
||||
const taskRows = recentTasks.map(task => ({ kind: "task" as const, id: task.id, time: task.recordedAt, task }));
|
||||
const chatRows = (chats.data ?? []).flatMap(chat => {
|
||||
const agent = chat.conversationAgentId ? roster.get(chat.conversationAgentId) : null;
|
||||
return agent ? [{ kind: "chat" as const, id: chat.id, time: new Date(chat.updatedAt).getTime(), agent }] : [];
|
||||
});
|
||||
const projectRows = (projects ?? []).flatMap(project => projectVisits.scope === scope && projectVisits.entries[project.id]
|
||||
? [{ kind: "project" as const, id: project.id, time: projectVisits.entries[project.id]!, project }] : []);
|
||||
return [...taskRows, ...chatRows, ...projectRows].sort((a, b) => b.time - a.time).slice(0, 12);
|
||||
}, [agents.data, chats.data, recentTasks, projects, projectVisits, scope]);
|
||||
return <FlyoutAppearanceProvider><TruncatedTextMarqueeProvider><aside className="coordinator-sidebar design-sidebar w-full">
|
||||
<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} />
|
||||
<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()}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity group-hover/coordinator-new:opacity-100 group-focus-within/coordinator-new:opacity-100 pointer-coarse:opacity-100 hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring"><Plus className="size-4" /></button>}
|
||||
</div>
|
||||
<SidebarNavItem className="design-sidebar-item" to="/decisions" label="Needs You" icon={Bell} badge={attention?.totalCount ?? 0} badgeLabel="decisions" />
|
||||
<Flyout open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<FlyoutTrigger asChild><button type="button" aria-label="More workspace pages" className="design-sidebar-item flex w-auto items-center gap-2.5 rounded-lg py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring"><MoreHorizontal className="size-4 shrink-0" /><span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>More</span></button></FlyoutTrigger>
|
||||
<FlyoutContent ref={moreContentRef} tabIndex={-1} onOpenAutoFocus={(event) => { event.preventDefault(); moreContentRef.current?.focus(); }} side={isMobile ? "bottom" : "right"} align="start" className="w-56 p-1" aria-label="More workspace pages">
|
||||
{moreDestinations.map(({ label, to, icon: Icon }, index) => {
|
||||
const pinned = pinnedNavigation.pins.includes(to);
|
||||
const PinIcon = pinned ? PinOff : Pin;
|
||||
return <div key={to}>
|
||||
{index === 6 || index === 10 ? <div role="separator" className="my-1 border-t border-border" /> : null}
|
||||
<div className="group/coordinator-more relative flex items-center rounded-sm transition-colors hover:bg-accent">
|
||||
<Link to={to} onClick={() => { setMoreOpen(false); if (isMobile) setSidebarOpen(false); }} className="flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-1.5 pr-10 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />{label}
|
||||
</Link>
|
||||
<button type="button" aria-label={`${pinned ? "Unpin" : "Pin"} ${label}`} aria-pressed={pinned} title={`${pinned ? "Unpin" : "Pin"} ${label}`} disabled={!pinnedNavigation.canPin} onClick={() => pinnedNavigation.toggle(to)} className={cn("absolute right-0.5 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-sm opacity-0 transition-opacity outline-none group-hover/coordinator-more:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", pinned ? "text-foreground" : "text-muted-foreground")}>
|
||||
<PinIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</FlyoutContent>
|
||||
</Flyout>
|
||||
</div>
|
||||
{pinnedDestinations.length > 0 && <section className="space-y-1 pt-4" aria-label="Pinned pages">
|
||||
{!rail && <div data-slot="sidebar-group-label" className="px-4 pb-1 text-xs text-muted-foreground">Pinned</div>}
|
||||
{pinnedDestinations.map(({ label, to, icon }) => <div key={to} className="group/coordinator-pin relative">
|
||||
<SidebarNavItem to={to} label={label} icon={icon} className={cn("design-sidebar-item", !rail && "pr-9")} />
|
||||
{!rail && <button type="button" aria-label={`Unpin ${label}`} title={`Unpin ${label}`} onClick={() => pinnedNavigation.toggle(to)} className="absolute right-3 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover/coordinator-pin:opacity-100 group-focus-within/coordinator-pin:opacity-100 pointer-coarse:opacity-100"><PinOff className="size-3.5" /></button>}
|
||||
</div>)}
|
||||
</section>}
|
||||
{pinnedNavigation.error && <p role="alert" className="px-4 py-2 text-xs text-destructive">{pinnedNavigation.error}</p>}
|
||||
{!rail && <div className="design-sidebar-recents"><SidebarSection label="Recents">
|
||||
<div className="design-sidebar-recent-list scrollbar-while-scrolling" onScroll={handleRecentScroll} role="region" aria-label="Recents" tabIndex={0}>
|
||||
{session.isSuccess && recents.map(item => item.kind === "task"
|
||||
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon status={item.task.status} externalConversationState={item.task.externalConversationState} />} />
|
||||
: item.kind === "chat"
|
||||
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} icon={MessageCircle} />
|
||||
: <ProjectRecent key={item.id} project={item.project} />)}
|
||||
{session.isSuccess && recents.length === 0 && <p className="px-4 text-xs leading-relaxed text-muted-foreground">Your conversations and tasks will appear here.</p>}
|
||||
</div>
|
||||
</SidebarSection></div>}
|
||||
{children}
|
||||
</nav>
|
||||
</aside></TruncatedTextMarqueeProvider></FlyoutAppearanceProvider>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CoordinatorWorkspaceSetting } from "./CoordinatorWorkspaceSetting";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(), updateExperimental: vi.fn(), access: vi.fn(), setEnabled: vi.fn(),
|
||||
enabled: false, hidden: new Set<string>(),
|
||||
}));
|
||||
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: mocks.getExperimental, updateExperimental: mocks.updateExperimental } }));
|
||||
vi.mock("@/api/access", () => ({ accessApi: { getCurrentBoardAccess: mocks.access } }));
|
||||
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: { id: "company" } }) }));
|
||||
vi.mock("@/hooks/useHiddenSettings", () => ({ useHiddenSettings: () => ({ hidden: mocks.hidden, loaded: true }) }));
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({
|
||||
enabled: mocks.enabled, loaded: true, userId: "person", error: null, isSaving: false, setEnabled: mocks.setEnabled,
|
||||
}) }));
|
||||
|
||||
describe("Coordinator workspace prerequisite", () => {
|
||||
let container: HTMLDivElement, root: Root, client: QueryClient;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); mocks.enabled = false; mocks.hidden.clear();
|
||||
mocks.getExperimental.mockResolvedValue({ enableAgentChat: false });
|
||||
mocks.access.mockResolvedValue({ userId: "person", source: "session", isInstanceAdmin: true });
|
||||
mocks.setEnabled.mockResolvedValue(undefined);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); });
|
||||
async function mount() {
|
||||
flushSync(() => root.render(<QueryClientProvider client={client}><CoordinatorWorkspaceSetting /></QueryClientProvider>));
|
||||
for (let i = 0; i < 10; i++) await new Promise(resolve => window.setTimeout(resolve, 0));
|
||||
flushSync(() => {});
|
||||
}
|
||||
it("links an administrator to the separate capability without enabling it with the preference", async () => {
|
||||
await mount();
|
||||
expect(container.textContent).toContain("Agent Chat is off");
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBe("#experimental-enableAgentChat");
|
||||
flushSync(() => container.querySelector<HTMLButtonElement>('[role="switch"]')!.click());
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith("company", true);
|
||||
expect(mocks.updateExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps turning the personal workspace off available to a non-admin", async () => {
|
||||
mocks.enabled = true;
|
||||
mocks.access.mockResolvedValue({ userId: "person", source: "session", isInstanceAdmin: false });
|
||||
await mount();
|
||||
expect(container.textContent).toContain("Ask an administrator");
|
||||
expect(container.querySelector("a")).toBeNull();
|
||||
flushSync(() => container.querySelector<HTMLButtonElement>('[role="switch"]')!.click());
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith("company", false);
|
||||
});
|
||||
it.each(["managed", "hidden"])("does not offer an editable link for %s controls", async kind => {
|
||||
if (kind === "managed") mocks.getExperimental.mockResolvedValue({ enableAgentChat: false, managedKeys: { enableAgentChat: { managed: true } } });
|
||||
else mocks.hidden.add("instance.experimental.enableAgentChat");
|
||||
await mount();
|
||||
expect(container.textContent).toContain("managed by your hosting provider");
|
||||
expect(container.querySelector("a")).toBeNull();
|
||||
});
|
||||
it("does not invent disabled capability state or block personal settings after a capability read error", async () => {
|
||||
mocks.getExperimental.mockRejectedValue(new Error("Unavailable"));
|
||||
await mount();
|
||||
expect(container.textContent).not.toContain("Agent Chat is off");
|
||||
expect(container.querySelector<HTMLButtonElement>('[role="switch"]')!.disabled).toBe(false);
|
||||
expect(mocks.access).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { experimentalSettingKey } from "@paperclipai/shared";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
|
||||
export function CoordinatorWorkspaceSetting() {
|
||||
const { selectedCompany } = useCompany();
|
||||
const workspace = useCoordinatorWorkspaceEnabled();
|
||||
const { hidden, loaded: visibilityLoaded } = useHiddenSettings();
|
||||
const experimental = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const chatDisabled = experimental.isSuccess && experimental.data.enableAgentChat !== true;
|
||||
const boardAccess = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
enabled: chatDisabled && !!workspace.userId,
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const chatManaged = experimental.data?.managedKeys?.enableAgentChat?.managed === true;
|
||||
const chatHidden = hidden.has(experimentalSettingKey("enableAgentChat"));
|
||||
const canEnableChat = visibilityLoaded && !chatHidden && !chatManaged && boardAccess.data?.userId === workspace.userId && (
|
||||
boardAccess.data?.source === "local_implicit" || boardAccess.data?.isInstanceAdmin === true
|
||||
);
|
||||
const error = workspace.error instanceof Error ? workspace.error.message : null;
|
||||
return (
|
||||
<Card className="block bg-transparent p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h3 id="coordinator-workspace-title" className="text-sm font-medium">Coordinator workspace</h3>
|
||||
<p id="coordinator-workspace-description" className="text-sm text-muted-foreground">
|
||||
Try the new Chat, Tasks, and Needs You workspace. This changes your interface across companies on this instance.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Off by default. Turn it off to return to the classic workspace. Your work and your teammates’ settings stay the same.
|
||||
</p>
|
||||
{workspace.userId === "local-board" && (
|
||||
<p className="text-xs text-muted-foreground">This local instance uses the shared Local Board identity.</p>
|
||||
)}
|
||||
{chatDisabled && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agent Chat is off for this instance. Your workspace preference does not enable it.{" "}
|
||||
{canEnableChat ? (
|
||||
<a href="#experimental-enableAgentChat" className="underline underline-offset-4">Enable Agent Chat below</a>
|
||||
) : chatManaged || chatHidden ? (
|
||||
"Chat availability is managed by your hosting provider."
|
||||
) : (
|
||||
"Ask an administrator to enable Agent Chat in Experimental settings."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={workspace.enabled}
|
||||
disabled={!workspace.loaded || workspace.isSaving || !workspace.userId || !selectedCompany || !!workspace.error}
|
||||
aria-label="Enable coordinator workspace"
|
||||
aria-describedby="coordinator-workspace-description"
|
||||
onCheckedChange={(enabled) => {
|
||||
if (!selectedCompany) return;
|
||||
// The hook exposes the error for the account that attempted the save.
|
||||
void workspace.setEnabled(selectedCompany.id, enabled).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{workspace.isSaving && <p role="status" className="mt-3 text-sm text-muted-foreground">Saving your workspace…</p>}
|
||||
{!workspace.loaded && <p role="status" className="mt-3 text-sm text-muted-foreground">Loading your preference…</p>}
|
||||
{error && (
|
||||
<div role="alert" className="mt-3 flex items-center gap-2 text-sm text-destructive">
|
||||
<span>{error}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => { void workspace.retry(); }}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Keeps a decision's task reference and its existing controls in one wrapping row. */
|
||||
export function DecisionCardFooter({ leading, children, className }: {
|
||||
leading?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!leading) return <>{children}</>;
|
||||
return (
|
||||
<div data-decision-footer className={cn("flex flex-wrap items-center justify-between gap-x-4 gap-y-3", className)}>
|
||||
<div className="min-w-0 flex-1 basis-48">{leading}</div>
|
||||
{children ? <div className="ml-auto max-w-full">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DecisionOutcome } from "../api/decisions";
|
||||
|
||||
const state = vi.hoisted(() => ({ issueStatus: "todo", queriedIssueIds: [] as string[] }));
|
||||
|
||||
@@ -58,8 +59,8 @@ vi.mock("../context/CompanyContext", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./DecisionCard", () => ({
|
||||
DecisionCard: ({ resolveIssue }: { resolveIssue: (id: string) => { status: string | null } | null }) => (
|
||||
<div data-testid="resolved-status">{resolveIssue("target-1")?.status}</div>
|
||||
DecisionCard: ({ resolveIssue, decision }: { resolveIssue: (id: string) => { status: string | null } | null; decision: DecisionOutcome }) => (
|
||||
<><div data-testid="resolved-status">{resolveIssue("target-1")?.status}</div><div data-testid="decision-status">{decision.status}</div></>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -99,6 +100,15 @@ describe("DecisionResolver", () => {
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders a fresh terminal record instead of an older open decision in the shared cache", () => {
|
||||
const record = { id: "decision-1", companyId: "company-1", status: "decided", options: [], targetSnapshots: {}, executions: [] } as unknown as DecisionOutcome;
|
||||
const root = createRoot(container);
|
||||
flushSync(() => root.render(<DecisionResolver companyId="company-1" decisionId="decision-1" recordedDecision={record} />));
|
||||
expect(container.querySelector('[data-testid="decision-status"]')?.textContent).toBe("decided");
|
||||
expect(state.queriedIssueIds).toEqual([]);
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("loads every primary and secondary effect target", () => {
|
||||
const root = createRoot(container);
|
||||
flushSync(() => root.render(<DecisionResolver companyId="company-1" decisionId="decision-1" />));
|
||||
|
||||
@@ -16,6 +16,8 @@ interface DecisionResolverProps {
|
||||
agentMap?: Map<string, Agent>;
|
||||
/** Complete terminal list item used to avoid one detail request per history row. */
|
||||
initialDecision?: DecisionOutcome;
|
||||
/** Fresh, authorized terminal record owned by an enclosing detail query. */
|
||||
recordedDecision?: DecisionOutcome;
|
||||
/** Called after a decide/dismiss so the parent can refresh the feed row. */
|
||||
onResolved?: () => void;
|
||||
}
|
||||
@@ -40,7 +42,7 @@ export function signedCancelTreePreviewIds(targetIssueId: string, snapshot: { de
|
||||
* dismiss mutations, and invalidates the attention feed + target-issue keys on
|
||||
* success — same conventions as {@link AttentionInteractionResolver}.
|
||||
*/
|
||||
export function DecisionResolver({ companyId, decisionId, originIssue, agentMap, initialDecision, onResolved }: DecisionResolverProps) {
|
||||
export function DecisionResolver({ companyId, decisionId, originIssue, agentMap, initialDecision, recordedDecision, onResolved }: DecisionResolverProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompany } = useCompany();
|
||||
const prefix = selectedCompany?.issuePrefix ?? "";
|
||||
@@ -52,11 +54,11 @@ export function DecisionResolver({ companyId, decisionId, originIssue, agentMap,
|
||||
const detail = useQuery({
|
||||
queryKey: queryKeys.decisions.detail(decisionId),
|
||||
queryFn: () => decisionsApi.get(decisionId),
|
||||
enabled: !!decisionId,
|
||||
enabled: !!decisionId && !recordedDecision,
|
||||
initialData: initialDecision,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const decision = detail.data;
|
||||
const decision = recordedDecision ?? detail.data;
|
||||
|
||||
// Shared across every open decision row → a single fetch carries `targetChanged`.
|
||||
const openList = useQuery({
|
||||
@@ -163,7 +165,7 @@ export function DecisionResolver({ companyId, decisionId, originIssue, agentMap,
|
||||
},
|
||||
});
|
||||
|
||||
if (detail.isLoading) {
|
||||
if (!recordedDecision && detail.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading decision…
|
||||
@@ -171,7 +173,7 @@ export function DecisionResolver({ companyId, decisionId, originIssue, agentMap,
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.error || !decision) {
|
||||
if ((!recordedDecision && detail.error) || !decision) {
|
||||
return (
|
||||
<p className="py-3 text-xs text-muted-foreground">
|
||||
This decision is no longer available — it may have been resolved elsewhere.
|
||||
|
||||
@@ -26,6 +26,7 @@ const markdownEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyChange: false,
|
||||
/** Typed into the editor on mount, for forms the test cannot type into. */
|
||||
emitMountValue: null as string | null,
|
||||
changeBody: null as null | ((body: string) => void),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -60,9 +61,8 @@ vi.mock("../api/auth", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ hash: "" }),
|
||||
}));
|
||||
const mockLocation = vi.hoisted(() => ({ hash: "" }));
|
||||
vi.mock("@/lib/router", () => ({ useLocation: () => mockLocation }));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({
|
||||
@@ -103,6 +103,7 @@ vi.mock("./MarkdownEditor", async () => {
|
||||
onChange?.("");
|
||||
}, []);
|
||||
|
||||
markdownEditorMockState.changeBody = onChange ?? null;
|
||||
return (
|
||||
<div className={contentClassName} data-testid="markdown-editor">
|
||||
{value || placeholder || ""}
|
||||
@@ -301,13 +302,91 @@ describe("IssueDocumentsSection", () => {
|
||||
vi.clearAllMocks();
|
||||
markdownEditorMockState.emitMountEmptyChange = false;
|
||||
markdownEditorMockState.emitMountValue = null;
|
||||
markdownEditorMockState.changeBody = null;
|
||||
mockSession.unreachable = false;
|
||||
mockLocation.hash = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
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 } } });
|
||||
mockLocation.hash = "#document-plan&revision=revision-3&interaction=review-1";
|
||||
localStorage.setItem("paperclip:issue-document-folds:issue-1", JSON.stringify(["plan"]));
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument({ body: "Newer unreviewed text" })]);
|
||||
let loadHistory!: (value: DocumentRevision[]) => void;
|
||||
mockIssuesApi.listDocumentRevisions.mockReturnValue(new Promise<DocumentRevision[]>((resolve) => { loadHistory = resolve; }));
|
||||
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
|
||||
await act(async () => root.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(mockIssuesApi.listDocumentRevisions).toHaveBeenCalledWith("issue-1", "plan");
|
||||
expect(scroll).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Loading reviewed revision");
|
||||
expect(container.textContent).not.toContain("Newer unreviewed text");
|
||||
await act(async () => loadHistory([createRevision({ body: "Exact reviewed text" })]));
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Viewing revision 3");
|
||||
expect(container.textContent).toContain("Exact reviewed text");
|
||||
expect(container.textContent).not.toContain("Newer unreviewed text");
|
||||
const latest = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Return to latest"));
|
||||
expect(latest).toBeTruthy();
|
||||
await act(async () => latest!.click());
|
||||
expect(container.textContent).toContain("Newer unreviewed text");
|
||||
await act(async () => root.unmount());
|
||||
scroll.mockRestore();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("does not substitute newer content when a Classic review revision is missing", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
mockLocation.hash = "#document-plan&revision=missing&interaction=review-1";
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument({ body: "Newer unreviewed text" })]);
|
||||
mockIssuesApi.listDocumentRevisions.mockResolvedValue([]);
|
||||
await act(async () => root.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>));
|
||||
await flush();
|
||||
await flush();
|
||||
expect(container.textContent).toContain("The reviewed revision is unavailable");
|
||||
expect(container.textContent).not.toContain("Newer unreviewed text");
|
||||
await act(async () => root.unmount());
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it.each(["loaded", "failed"])("preserves an unsaved draft when linked revision loading %s", async (outcome) => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const storageKey = "paperclip:document-draft:v2:user-1:issue-1:plan";
|
||||
localStorage.setItem(storageKey, JSON.stringify({ key: "plan", title: "Plan", body: "My unsaved draft", baseRevisionId: "revision-4", isNew: false }));
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument({ body: "Saved latest body" })]);
|
||||
let load!: (value: DocumentRevision[]) => void;
|
||||
let fail!: (error: Error) => void;
|
||||
mockIssuesApi.listDocumentRevisions.mockReturnValue(new Promise<DocumentRevision[]>((resolve, reject) => { load = resolve; fail = reject; }));
|
||||
const show = () => root.render(<QueryClientProvider client={queryClient}><IssueDocumentsSection issue={createIssue()} canDeleteDocuments={false} /></QueryClientProvider>);
|
||||
await act(async () => show());
|
||||
await flush();
|
||||
await flush();
|
||||
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("My unsaved draft");
|
||||
mockLocation.hash = "#document-plan&revision=revision-3&interaction=review-1";
|
||||
await act(async () => show());
|
||||
await flush();
|
||||
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("My unsaved draft");
|
||||
expect(JSON.parse(localStorage.getItem(storageKey)!).body).toBe("My unsaved draft");
|
||||
await act(async () => outcome === "loaded" ? load([createRevision()]) : fail(new Error("Disconnected")));
|
||||
await flush();
|
||||
await flush();
|
||||
expect(container.textContent).toContain(outcome === "loaded" ? "Save or cancel your local changes" : "Could not load the reviewed revision");
|
||||
expect(container.querySelector('[data-testid="markdown-editor"]')?.textContent).toBe("My unsaved draft");
|
||||
await act(async () => markdownEditorMockState.changeBody!("My unsaved draft, continued"));
|
||||
await flush();
|
||||
expect(JSON.parse(localStorage.getItem(storageKey)!).body).toBe("My unsaved draft, continued");
|
||||
expect(mockIssuesApi.upsertDocument).not.toHaveBeenCalled();
|
||||
await act(async () => root.unmount());
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("keeps system handoff documents out of the normal document surface", async () => {
|
||||
const issue = createIssue();
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { describeError } from "../api/errors";
|
||||
import { loadStructuredDraft, saveStructuredDraft } from "../lib/composer-draft";
|
||||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
||||
import { parseDocumentAnnotationHash } from "../lib/document-annotation-hash";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useDraftOwner } from "../hooks/useDraftOwner";
|
||||
@@ -354,6 +355,9 @@ function AccountIssueDocumentsSection({
|
||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
||||
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
||||
const [handledRevisionHash, setHandledRevisionHash] = useState<string | null>(null);
|
||||
const documentHashTarget = useMemo(() => parseDocumentAnnotationHash(location.hash), [location.hash]);
|
||||
const revisionsQueryDocumentKey = revisionMenuOpenKey ?? (documentHashTarget?.revisionId ? documentHashTarget.documentKey : null);
|
||||
const [diffViewKey, setDiffViewKey] = useState<string | null>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -375,15 +379,15 @@ function AccountIssueDocumentsSection({
|
||||
queryFn: documentSubject.listDocuments,
|
||||
});
|
||||
|
||||
const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions } = useQuery({
|
||||
queryKey: revisionMenuOpenKey
|
||||
? documentSubject.documentRevisionsQueryKey(revisionMenuOpenKey)
|
||||
const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions, isError: revisionsQueryFailed } = useQuery({
|
||||
queryKey: revisionsQueryDocumentKey
|
||||
? documentSubject.documentRevisionsQueryKey(revisionsQueryDocumentKey)
|
||||
: documentSubject.idleDocumentRevisionsQueryKey,
|
||||
queryFn: async () => {
|
||||
if (!revisionMenuOpenKey) return [];
|
||||
return documentSubject.listDocumentRevisions(revisionMenuOpenKey);
|
||||
if (!revisionsQueryDocumentKey) return [];
|
||||
return documentSubject.listDocumentRevisions(revisionsQueryDocumentKey);
|
||||
},
|
||||
enabled: Boolean(revisionMenuOpenKey),
|
||||
enabled: Boolean(revisionsQueryDocumentKey),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = useCallback(() => {
|
||||
@@ -777,9 +781,9 @@ function AccountIssueDocumentsSection({
|
||||
const getDocumentRevisions = useCallback((key: string) => {
|
||||
const cached = queryClient.getQueryData<DocumentRevision[]>(documentSubject.documentRevisionsQueryKey(key));
|
||||
if (cached) return cached;
|
||||
if (revisionMenuOpenKey === key) return activeDocumentRevisions ?? [];
|
||||
if (revisionsQueryDocumentKey === key) return activeDocumentRevisions ?? [];
|
||||
return [];
|
||||
}, [activeDocumentRevisions, documentSubject, queryClient, revisionMenuOpenKey]);
|
||||
}, [activeDocumentRevisions, documentSubject, queryClient, revisionsQueryDocumentKey]);
|
||||
|
||||
const returnToLatestRevision = useCallback((key: string) => {
|
||||
setSelectedRevisionIds((current) => ({ ...current, [key]: null }));
|
||||
@@ -789,14 +793,14 @@ function AccountIssueDocumentsSection({
|
||||
const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => {
|
||||
const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key));
|
||||
const selectedRevision = revisionState.revisions.find((revision) => revision.id === revisionId);
|
||||
if (!selectedRevision) return;
|
||||
if (selectedRevision.id === revisionState.currentRevision.id) {
|
||||
returnToLatestRevision(doc.key);
|
||||
return;
|
||||
}
|
||||
if (!selectedRevision) return false;
|
||||
if (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft)) {
|
||||
setError("Save or cancel your local changes before viewing an older revision.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (selectedRevision.id === revisionState.currentRevision.id) {
|
||||
returnToLatestRevision(doc.key);
|
||||
return true;
|
||||
}
|
||||
resetAutosaveState();
|
||||
setDraft((current) => current?.key === doc.key ? null : current);
|
||||
@@ -804,6 +808,7 @@ function AccountIssueDocumentsSection({
|
||||
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== doc.key));
|
||||
setSelectedRevisionIds((current) => ({ ...current, [doc.key]: selectedRevision.id }));
|
||||
setError(null);
|
||||
return true;
|
||||
}, [documentConflict, draft, getDocumentRevisions, resetAutosaveState, returnToLatestRevision]);
|
||||
|
||||
const toggleDocumentLock = useCallback((doc: IssueDocument, locked: boolean) => {
|
||||
@@ -840,6 +845,8 @@ function AccountIssueDocumentsSection({
|
||||
|
||||
useEffect(() => {
|
||||
setFoldedDocumentKeys(loadFoldedDocumentKeys(documentSubject.id));
|
||||
setHandledRevisionHash(null);
|
||||
setSelectedRevisionIds({});
|
||||
}, [documentSubject.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -873,9 +880,8 @@ function AccountIssueDocumentsSection({
|
||||
}, [documentConflict, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!hash.startsWith("#document-")) return;
|
||||
const documentKey = decodeURIComponent(hash.slice("#document-".length));
|
||||
const documentKey = documentHashTarget?.documentKey;
|
||||
if (!documentKey) return;
|
||||
const targetExists = sortedDocuments.some((doc) => doc.key === documentKey)
|
||||
|| (documentKey === "plan" && Boolean(documentSubject.legacyPlanDocument));
|
||||
if (!targetExists || hasScrolledToHashRef.current) return;
|
||||
@@ -887,7 +893,25 @@ function AccountIssueDocumentsSection({
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [documentSubject.legacyPlanDocument, location.hash, sortedDocuments]);
|
||||
}, [documentSubject.legacyPlanDocument, documentHashTarget, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentHashTarget?.revisionId || handledRevisionHash === location.hash) return;
|
||||
const doc = sortedDocuments.find((entry) => entry.key === documentHashTarget.documentKey);
|
||||
if (!doc) return;
|
||||
if (revisionsQueryFailed) {
|
||||
setError("Could not load the reviewed revision. Try opening the link again.");
|
||||
return;
|
||||
}
|
||||
if (!getDocumentRevisions(doc.key).some((revision) => revision.id === documentHashTarget.revisionId)
|
||||
&& doc.latestRevisionId !== documentHashTarget.revisionId) {
|
||||
if (!isFetchingDocumentRevisions && activeDocumentRevisions !== undefined) {
|
||||
setError("The reviewed revision is unavailable.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (previewRevision(doc, documentHashTarget.revisionId)) setHandledRevisionHash(location.hash);
|
||||
}, [activeDocumentRevisions, documentHashTarget, getDocumentRevisions, handledRevisionHash, isFetchingDocumentRevisions, location.hash, previewRevision, revisionsQueryFailed, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1111,6 +1135,8 @@ function AccountIssueDocumentsSection({
|
||||
const displayedTitle = selectedHistoricalRevision
|
||||
? selectedHistoricalRevision.title ?? ""
|
||||
: activeDraft?.title ?? currentRevision.title ?? "";
|
||||
const waitingForLinkedRevision = documentHashTarget?.documentKey === doc.key
|
||||
&& Boolean(documentHashTarget.revisionId) && handledRevisionHash !== location.hash;
|
||||
const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body;
|
||||
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber;
|
||||
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt;
|
||||
@@ -1263,6 +1289,11 @@ function AccountIssueDocumentsSection({
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{waitingForLinkedRevision ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{error ?? "Loading reviewed revision…"}
|
||||
</p>
|
||||
) : null}
|
||||
{isHistoricalPreview && selectedHistoricalRevision && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -1377,7 +1408,9 @@ function AccountIssueDocumentsSection({
|
||||
}`}
|
||||
>
|
||||
{(() => {
|
||||
const renderedDocumentBody = isHistoricalPreview ? (
|
||||
// Loading and error copy must never become editor input
|
||||
// or annotation content. A blocked draft stays editable.
|
||||
const renderedDocumentBody = waitingForLinkedRevision && !activeDraft ? null : isHistoricalPreview ? (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
|
||||
) : activeDraft ? (
|
||||
<MarkdownEditor
|
||||
@@ -1403,7 +1436,7 @@ function AccountIssueDocumentsSection({
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
|
||||
);
|
||||
|
||||
return documentSubject.annotations ? (
|
||||
return documentSubject.annotations && (!waitingForLinkedRevision || activeDraft) ? (
|
||||
<IssueDocumentAnnotations
|
||||
issueId={documentSubject.annotations.issueId}
|
||||
target={annotationTarget}
|
||||
|
||||
@@ -200,7 +200,9 @@ describe("IssueThreadInteractionCard", () => {
|
||||
host.remove();
|
||||
const reopened = renderCard({ interaction: canonicalDraftInteraction, onSubmitInteractionAnswers: submit });
|
||||
expect(reopened.querySelector<HTMLTextAreaElement>("textarea")!.value).toBe("");
|
||||
expect(Array.from(reopened.querySelectorAll("button")).find((button) => button.textContent === "Submit answers")!.disabled).toBe(true);
|
||||
expect(reopened.querySelector('[role="alert"]')).toBeNull();
|
||||
await act(() => Array.from(reopened.querySelectorAll("button")).find((button) => button.textContent === "Submit answers")!.click());
|
||||
expect(reopened.textContent).toContain("This question is required.");
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -284,6 +286,67 @@ describe("IssueThreadInteractionCard", () => {
|
||||
expect(otherLink?.className).toContain("underline");
|
||||
});
|
||||
|
||||
it("keeps Coordinator decision questions focused while preserving answers and accessible requirements", async () => {
|
||||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
presentationVariant: "coordinator-decision",
|
||||
interaction: pendingAskUserQuestionsInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
});
|
||||
expect(host.querySelector('[data-presentation="coordinator-decision"]')?.classList.contains("border")).toBe(false);
|
||||
expect(host.querySelector('[data-testid="interaction-status-badge"]')).toBeNull();
|
||||
expect(host.textContent).not.toContain("Ask user questions");
|
||||
expect(host.textContent).not.toContain("Question 1");
|
||||
expect(host.textContent).not.toContain("proposed by");
|
||||
const group = host.querySelector('[role="radiogroup"]');
|
||||
expect(group?.getAttribute("aria-required")).toBe("true");
|
||||
expect(group?.getAttribute("aria-describedby")).toBe("interaction-questions-default-collapse-depth-requirements");
|
||||
await act(() => {
|
||||
(host.querySelector('[role="radio"]') as HTMLButtonElement).click();
|
||||
(host.querySelector('[role="checkbox"]') as HTMLButtonElement).click();
|
||||
});
|
||||
const submit = [...host.querySelectorAll("button")].find(button => button.textContent === "Send answers")!;
|
||||
await act(() => submit.click());
|
||||
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(pendingAskUserQuestionsInteraction, [
|
||||
{ questionId: "collapse-depth", optionIds: ["visible-root"] },
|
||||
{ questionId: "post-submit-summary", optionIds: ["answers-inline"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Coordinator confirmation details collapsed while preserving context, exact target, and approval", async () => {
|
||||
const onAcceptInteraction = vi.fn(async () => undefined);
|
||||
const interaction = { ...pendingRequestConfirmationInteraction, title: "Repeated approval context", summary: "Repeated approval context", payload: { ...pendingRequestConfirmationInteraction.payload, prompt: "Repeated approval context", detailsMarkdown: "Revision proof: sha256-test-proof" } };
|
||||
const host = renderCard({ presentationVariant: "coordinator-decision", interaction, onAcceptInteraction });
|
||||
expect(host.textContent?.split("Repeated approval context")).toHaveLength(2);
|
||||
expect(host.querySelector('[data-testid="interaction-status-badge"]')).toBeNull();
|
||||
const documentLink = host.querySelector('a[href*="#document-"]');
|
||||
expect(documentLink?.getAttribute("href")).toContain("revision=11111111-1111-4111-8111-111111111111");
|
||||
expect(documentLink?.getAttribute("href")).toContain(`interaction=${interaction.id}`);
|
||||
const details = host.querySelector("details")!;
|
||||
expect(details.open).toBe(false);
|
||||
expect(details.querySelector("summary")?.textContent?.trim()).toBe("Details");
|
||||
expect(details.textContent).toContain("Revision proof: sha256-test-proof");
|
||||
expect(documentLink?.closest("details")).toBeNull();
|
||||
expect(host.querySelector("[data-decision-prompt]")?.closest("details")).toBeNull();
|
||||
await act(() => details.querySelector("summary")!.click());
|
||||
expect(details.open).toBe(true);
|
||||
await act(() => details.querySelector("summary")!.click());
|
||||
expect(details.open).toBe(false);
|
||||
const approve = [...host.querySelectorAll("button")].find(button => button.textContent?.includes(interaction.payload.acceptLabel ?? "Approve"))!;
|
||||
expect(approve.closest("details")).toBeNull();
|
||||
await act(() => approve.click());
|
||||
expect(onAcceptInteraction).toHaveBeenCalledWith(interaction);
|
||||
});
|
||||
|
||||
it("retains inline confirmation details in the Classic presentation", () => {
|
||||
const host = renderCard({ interaction: {
|
||||
...pendingRequestConfirmationInteraction,
|
||||
payload: { ...pendingRequestConfirmationInteraction.payload, detailsMarkdown: "Original review context" },
|
||||
} });
|
||||
expect(host.textContent).toContain("Original review context");
|
||||
expect(host.querySelector("details")).toBeNull();
|
||||
});
|
||||
|
||||
it("submits written Other answers for pending questions", async () => {
|
||||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
@@ -1085,6 +1148,35 @@ describe("IssueThreadInteractionCard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a bound attachment directly without experimental panel routing", () => {
|
||||
const host = renderCard({ interaction: { ...pendingRequestItemVerdictsInteraction,
|
||||
payload: { ...pendingRequestItemVerdictsInteraction.payload, items: [{ id: "file", label: "Reviewed file", target: {
|
||||
type: "issue_attachment", attachmentId: "immutable-file", assetId: "asset", sha256: "hash",
|
||||
} }] },
|
||||
} });
|
||||
const link = host.querySelector('a[href="/api/attachments/immutable-file/content"]');
|
||||
expect(link?.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it.each([undefined, "/stale-display-link"])("opens a confirmation's bound attachment in Classic when href is %s", href => {
|
||||
const host = renderCard({ interaction: { ...pendingRequestConfirmationInteraction, payload: {
|
||||
version: 1, prompt: "Review the attached copy", target: {
|
||||
type: "issue_attachment", attachmentId: "immutable-file", assetId: "asset", sha256: "hash", href,
|
||||
},
|
||||
} } });
|
||||
expect(host.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it("links a bound review item to its exact document without requiring href", () => {
|
||||
const host = renderCard({ interaction: { ...pendingRequestItemVerdictsInteraction,
|
||||
payload: { ...pendingRequestItemVerdictsInteraction.payload, items: [{ id: "copy", label: "Reviewed copy", target: {
|
||||
type: "issue_document", issueId: "document-owner", key: "copy", revisionId: "reviewed-v1",
|
||||
} }] },
|
||||
} });
|
||||
expect(host.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href"))
|
||||
.toBe(`/issues/document-owner#document-copy&revision=reviewed-v1&interaction=${pendingRequestItemVerdictsInteraction.id}`);
|
||||
});
|
||||
|
||||
it("submits an approve verdict once a draft is marked and applied", async () => {
|
||||
const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,6 @@
|
||||
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
|
||||
import { CoordinatorChatReferencesProvider } from "./coordinator/CoordinatorChatReferences";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { useDesignSandbox } from "@/context/DesignSandboxContext";
|
||||
import { DesignSandboxSidebar } from "./DesignSandboxSidebar";
|
||||
import { BoardDialogs } from "./BoardDialogs";
|
||||
@@ -94,7 +97,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
setForceCollapsed,
|
||||
} = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialogActions();
|
||||
const { togglePanelVisible } = usePanel();
|
||||
const { togglePanelVisible, setPanelVisible } = usePanel();
|
||||
// Optional: Layout also renders in harnesses without a ToastProvider.
|
||||
const pushToast = useOptionalToastActions()?.pushToast ?? null;
|
||||
const {
|
||||
@@ -119,11 +122,16 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||
const { enabled: classicStreamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||
const { enabled: coordinatorWorkspaceEnabled } = useCoordinatorWorkspaceEnabled();
|
||||
const streamlinedUiEnabled = classicStreamlinedUiEnabled || coordinatorWorkspaceEnabled;
|
||||
const shellRoute = classifyShellRoute(location.pathname, companyPrefix);
|
||||
const isCompanySettingsRoute = shellRoute.builtInContextualSurface === "settings";
|
||||
const companyPathSegments = shellRoute.companySegments;
|
||||
const isTaskDetailRoute = shellRoute.isTaskDetail;
|
||||
useLayoutEffect(() => {
|
||||
if (coordinatorWorkspaceEnabled) setPanelVisible(false);
|
||||
}, [coordinatorWorkspaceEnabled, location.pathname, setPanelVisible]);
|
||||
const { enabled: agentChatEnabled } = useAgentChatEnabled();
|
||||
const isAgentChatRoute = agentChatEnabled && companyPathSegments[0]?.toLowerCase() === "chats";
|
||||
// Chat keeps its header beside the agent sidebar, including before an agent is selected.
|
||||
@@ -211,7 +219,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
const secondarySidebar = isAgentChatRoute ? <AgentConversationsSidebar /> : shellRoute.builtInContextualSurface === "agent" && agentId ? (
|
||||
const secondarySidebar = isAgentChatRoute ? (coordinatorWorkspaceEnabled ? null : <AgentConversationsSidebar />) : shellRoute.builtInContextualSurface === "agent" && agentId ? (
|
||||
<AgentContextualSidebar agentRef={agentId} />
|
||||
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
|
||||
<SetupWizardSidebarOutlet><RoutineContextualSidebar routineId={routineId} /></SetupWizardSidebarOutlet>
|
||||
@@ -574,12 +582,15 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
}, [location.key, location.pathname, location.state, navigationType]);
|
||||
|
||||
return (
|
||||
<CoordinatorPresentation.Provider value={coordinatorWorkspaceEnabled}>
|
||||
<CoordinatorChatReferencesProvider enabled={coordinatorWorkspaceEnabled}>
|
||||
<ChatSetupSidebarProvider>
|
||||
<div
|
||||
ref={mobileViewportRef}
|
||||
data-mobile={isMobile ? "true" : undefined}
|
||||
className={cn(
|
||||
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
|
||||
coordinatorWorkspaceEnabled && "coordinator-workspace",
|
||||
// overflow-x-clip on mobile keeps a stray wide descendant from making the
|
||||
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
|
||||
// computed as visible, so native body scroll + the sticky breadcrumb keep
|
||||
@@ -632,7 +643,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
collapsed={designSandbox || replacesPrimarySidebar ? false : collapsed}
|
||||
peeking={designSandbox || replacesPrimarySidebar ? false : peeking}
|
||||
resizable
|
||||
storageKey={designSandbox ? "paperclip.design-proposals.sandbox.sidebar-width" : undefined}
|
||||
storageKey={designSandbox ? "paperclip.design-proposals.sandbox.sidebar-width" : coordinatorWorkspaceEnabled ? "paperclip.coordinator.sidebar-width" : undefined}
|
||||
onPanelMouseEnter={replacesPrimarySidebar ? undefined : handlePanelPointerEnter}
|
||||
onPanelMouseLeave={replacesPrimarySidebar ? undefined : handlePanelPointerLeave}
|
||||
onPanelFocusCapture={!replacesPrimarySidebar && collapsed ? handlePanelFocus : undefined}
|
||||
@@ -758,5 +769,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
|
||||
</div>
|
||||
</ChatSetupSidebarProvider>
|
||||
</CoordinatorChatReferencesProvider>
|
||||
</CoordinatorPresentation.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
@@ -38,7 +42,7 @@ afterEach(() => {
|
||||
container = null;
|
||||
});
|
||||
|
||||
function renderMarkdown(children: string) {
|
||||
function renderMarkdown(children: string, coordinator = false) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -54,7 +58,9 @@ function renderMarkdown(children: string) {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<CoordinatorPresentation.Provider value={coordinator}>
|
||||
<MarkdownBody>{children}</MarkdownBody>
|
||||
</CoordinatorPresentation.Provider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -71,6 +77,17 @@ function click(element: Element | null) {
|
||||
}
|
||||
|
||||
describe("MarkdownBody code block interactions", () => {
|
||||
it("retains unavailable-task handling for compact document links", async () => {
|
||||
vi.mocked(issuesApi.get).mockRejectedValueOnce(new ApiError("Not found", 404, null));
|
||||
const node = renderMarkdown("[View recommendations](/issues/TES-6#document-recommendations)", true);
|
||||
const link = node.querySelector<HTMLAnchorElement>('[data-sidebar-document-link]')!;
|
||||
expect(link).toBeTruthy();
|
||||
await act(async () => { link.focus(); });
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
expect(node.querySelector('[data-testid="locked-issue-chip"]')).toBeTruthy();
|
||||
expect(node.querySelector("a")).toBeNull();
|
||||
expect(node.textContent).not.toContain("View recommendations");
|
||||
});
|
||||
it("toggles line wrapping for indented preformatted markdown blocks", () => {
|
||||
const node = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox");
|
||||
const pre = node.querySelector("pre");
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { MarkdownWordStreaming, rehypeStreamingWords } from "./MarkdownWordStreaming";
|
||||
import { isValidElement, memo, useContext, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, WrapText } from "lucide-react";
|
||||
import { Check, Copy, ExternalLink, MessageCircle, WrapText } from "lucide-react";
|
||||
import { useCoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
|
||||
import { useCoordinatorChatReference } from "./coordinator/CoordinatorChatReferences";
|
||||
import { SidebarDocumentLink } from "./SidebarDocumentLink";
|
||||
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -143,18 +147,21 @@ function MarkdownIssueLink({
|
||||
issuePathId,
|
||||
href,
|
||||
children,
|
||||
documentLink = false,
|
||||
}: {
|
||||
issuePathId: string;
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
documentLink?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const chatReference = useCoordinatorChatReference(issuePathId);
|
||||
const [engaged, setEngaged] = useState(false);
|
||||
const { data, error } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issuePathId),
|
||||
// A transcript can mention dozens of tasks. Their full detail projections
|
||||
// are hover information, not prerequisites for reading this conversation.
|
||||
enabled: engaged,
|
||||
enabled: engaged && (!chatReference || documentLink),
|
||||
placeholderData: getCachedIssueDetail(queryClient, issuePathId),
|
||||
queryFn: () => issuesApi.get(issuePathId),
|
||||
staleTime: 60_000,
|
||||
@@ -164,12 +171,25 @@ function MarkdownIssueLink({
|
||||
!(err instanceof ApiError && err.status === 404) && failureCount < 3,
|
||||
});
|
||||
|
||||
if (chatReference && !documentLink) {
|
||||
const suffix = href.match(/[?#].*$/)?.[0] ?? "";
|
||||
return <Link to={`${chatReference.href}${suffix}`} disableIssueQuicklook
|
||||
className="font-normal underline" data-mention-kind="chat" aria-label={chatReference.label}>
|
||||
<MessageCircle aria-hidden className="relative -top-px mr-1 inline-block size-4 align-middle" />{chatReference.label}
|
||||
</Link>;
|
||||
}
|
||||
// Mention of an issue this viewer can't read → existence-only locked chip
|
||||
// (never a title or a link), matching the locked-stub treatment on edges.
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return <LockedIssueChip identifier={issuePathId} unavailable />;
|
||||
}
|
||||
|
||||
if (documentLink) {
|
||||
return <SidebarDocumentLink href={href} onPointerEnter={() => setEngaged(true)} onFocus={() => setEngaged(true)}>
|
||||
<span className="min-w-0 truncate text-foreground underline underline-offset-4">{children}</span>
|
||||
</SidebarDocumentLink>;
|
||||
}
|
||||
|
||||
const identifier = data?.identifier ?? issuePathId;
|
||||
const title = data?.title ?? identifier;
|
||||
const status = data?.status;
|
||||
@@ -796,6 +816,7 @@ function MarkdownBodyImpl({
|
||||
resolveWorkspaceFileRef,
|
||||
}: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
const coordinator = useCoordinatorPresentation();
|
||||
const streamWords = useContext(MarkdownWordStreaming);
|
||||
// Read company prefixes non-throwingly: MarkdownBody renders in surfaces that
|
||||
// may lack a CompanyProvider. A null context (or no companies yet) leaves
|
||||
@@ -916,9 +937,16 @@ function MarkdownBodyImpl({
|
||||
}
|
||||
|
||||
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
||||
const documentHash = href?.includes("#") ? href.slice(href.indexOf("#")) : null;
|
||||
const documentLink = Boolean(coordinator && documentHash && parseDocumentAnnotationHash(documentHash));
|
||||
if (documentLink && href?.startsWith("#")) {
|
||||
return <SidebarDocumentLink href={href}>
|
||||
<span className="min-w-0 truncate text-foreground underline underline-offset-4">{linkChildren}</span>
|
||||
</SidebarDocumentLink>;
|
||||
}
|
||||
if (issueRef) {
|
||||
return (
|
||||
<MarkdownIssueLink issuePathId={issueRef.issuePathId} href={issueRef.href}>
|
||||
<MarkdownIssueLink issuePathId={issueRef.issuePathId} href={issueRef.href} documentLink={documentLink}>
|
||||
{linkChildren}
|
||||
</MarkdownIssueLink>
|
||||
);
|
||||
@@ -1024,7 +1052,7 @@ function MarkdownBodyImpl({
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, resolveLinkHref, onImageClick, mediaMode]);
|
||||
}, [theme, coordinator, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, resolveLinkHref, onImageClick, mediaMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,8 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
|
||||
import { CoordinatorChatReferences } from "./coordinator/CoordinatorChatReferences";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
@@ -21,18 +23,42 @@ vi.mock("../api/issues", () => ({
|
||||
issuesApi: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
function render(children: string, externalReferences?: MarkdownExternalReferenceMap) {
|
||||
function render(children: string, externalReferences?: MarkdownExternalReferenceMap, coordinator = false) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<CoordinatorPresentation.Provider value={coordinator}>
|
||||
<CoordinatorChatReferences.Provider value={new Map(coordinator ? [["tes-2", { label: "Chat with CTD", href: "/chats/ctd" }]] : [])}>
|
||||
<MarkdownBody externalReferences={externalReferences}>{children}</MarkdownBody>
|
||||
</CoordinatorChatReferences.Provider>
|
||||
</CoordinatorPresentation.Provider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MarkdownBody external object decoration", () => {
|
||||
it("labels a standing conversation by its agent and keeps its conversation anchor", () => {
|
||||
const html = render("Source: [TES-2](/TES/issues/TES-2#interaction-answer)", undefined, true);
|
||||
expect(html).toContain('href="/chats/ctd#interaction-answer"');
|
||||
expect(html).toContain('data-mention-kind="chat"');
|
||||
expect(html).toContain("Chat with CTD");
|
||||
expect(html).not.toContain("Issue TES-2");
|
||||
expect(html).not.toContain('data-mention-kind="issue"');
|
||||
expect(render("Source: TES-2")).toContain('data-mention-kind="issue"');
|
||||
});
|
||||
|
||||
it("uses a compact document card for sidebar links, preserving the exact revision", () => {
|
||||
const markdown = "[View saved recommendations](/TES/issues/TES-6#document-skill-recommendations&revision=reviewed-rev)";
|
||||
const html = render(markdown, undefined, true);
|
||||
expect(html).toContain('data-sidebar-document-link="true"');
|
||||
expect(html).toContain('href="/issues/TES-6#document-skill-recommendations&revision=reviewed-rev"');
|
||||
expect(html).toContain("View saved recommendations");
|
||||
expect(html).not.toContain('data-mention-kind="issue"');
|
||||
expect(render(markdown)).not.toContain("data-sidebar-document-link");
|
||||
expect(render("[External document](https://example.com/issues/TES-6#document-plan)", undefined, true)).not.toContain("data-sidebar-document-link");
|
||||
});
|
||||
const successReference: MarkdownExternalReferenceMap = {
|
||||
"https://github.com/acme/web/pull/241": {
|
||||
providerKey: "github",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
|
||||
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true }) }));
|
||||
vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: true }) }));
|
||||
vi.mock("@/hooks/useCombinedInboxTasksEnabled", () => ({ useCombinedInboxTasksEnabled: () => ({ enabled: false }) }));
|
||||
vi.mock("@/hooks/useInboxBadge", () => ({ useInboxBadge: () => ({ inbox: 0 }) }));
|
||||
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) }));
|
||||
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) }));
|
||||
vi.mock("@/lib/router", () => ({ useLocation: () => ({ pathname: "/chats" }), NavLink: ({ to, children }: { to: string; children: (state: { isActive: boolean }) => ReactNode }) => <a href={to}>{children({ isActive: false })}</a> }));
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
it("uses the canonical outstanding count on the phone as well as the desktop", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
const key = coordinatorAttentionQueryKey("company");
|
||||
client.setQueryData(key, { totalCount: 3, deskBadgeCount: 0 });
|
||||
const host = document.createElement("div"); document.body.append(host); const root = createRoot(host);
|
||||
try {
|
||||
await act(() => root.render(<QueryClientProvider client={client}><MobileBottomNav visible /></QueryClientProvider>));
|
||||
expect(host.querySelector('a[href="/decisions"]')?.textContent).toBe("3Needs You");
|
||||
await act(async () => { client.setQueryData(key, { totalCount: 0, deskBadgeCount: 0 }); await new Promise(resolve => setTimeout(resolve, 1)); });
|
||||
expect(host.querySelector('a[href="/decisions"]')?.textContent).toBe("Needs You");
|
||||
} finally { await act(() => root.unmount()); host.remove(); client.clear(); }
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { attentionApi } from "@/api/attention";
|
||||
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
|
||||
import { NavLink, useLocation } from "@/lib/router";
|
||||
import {
|
||||
House,
|
||||
@@ -7,6 +11,7 @@ import {
|
||||
Users,
|
||||
MessageCircle,
|
||||
Inbox,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
@@ -44,7 +49,13 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { enabled: agentChatEnabled } = useAgentChatEnabled();
|
||||
const { enabled: coordinatorWorkspaceEnabled } = useCoordinatorWorkspaceEnabled();
|
||||
const { enabled: combinedInboxTasksEnabled } = useCombinedInboxTasksEnabled();
|
||||
const { data: attention } = useQuery({
|
||||
queryKey: coordinatorAttentionQueryKey(selectedCompanyId!),
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }),
|
||||
enabled: coordinatorWorkspaceEnabled && !!selectedCompanyId,
|
||||
});
|
||||
|
||||
// PAP-670: with both flags off the bar is the original Home · Tasks · + ·
|
||||
// Agents · Inbox. Agent Chat adds Chat in the second slot (Home · Chat · + ·
|
||||
@@ -52,7 +63,12 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
// it is a view inside Tasks, so its unread badge rides on Tasks. The grid
|
||||
// tracks the live count, so the bar stays evenly divided in every mix.
|
||||
const items = useMemo<MobileNavItem[]>(
|
||||
() => !agentChatEnabled && !combinedInboxTasksEnabled ? [
|
||||
() => coordinatorWorkspaceEnabled ? [
|
||||
{ type: "link", to: "/chats", label: "Chat", icon: MessageCircle },
|
||||
{ type: "link", to: "/issues", label: "Tasks", icon: CircleCheck },
|
||||
{ type: "link", to: "/decisions", label: "Needs You", icon: Bell, badge: attention?.totalCount ?? 0 },
|
||||
{ type: "action", label: "New task", icon: SquarePen, onClick: () => openNewIssue() },
|
||||
] : !agentChatEnabled && !combinedInboxTasksEnabled ? [
|
||||
{ type: "link", to: "/dashboard", label: "Home", icon: House },
|
||||
{ type: "link", to: "/issues", label: "Tasks", icon: CircleCheck },
|
||||
{ type: "action", label: "New Task", icon: SquarePen, onClick: () => openNewIssue() },
|
||||
@@ -82,7 +98,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
? [{ type: "link", to: "/inbox", label: "Inbox", icon: Inbox, badge: inboxBadge.inbox } as MobileNavItem]
|
||||
: []),
|
||||
],
|
||||
[openNewIssue, inboxBadge.inbox, agentChatEnabled, combinedInboxTasksEnabled],
|
||||
[openNewIssue, inboxBadge.inbox, agentChatEnabled, combinedInboxTasksEnabled, coordinatorWorkspaceEnabled, attention?.totalCount],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,8 @@ import { addStagedFilesToIssue, NewIssueDialog } from "./NewIssueDialog";
|
||||
|
||||
const primaryState = vi.hoisted(() => ({ primaryAgentId: null as string | null, loading: false }));
|
||||
vi.mock("./primary-agent/PrimaryAgentPresentation", () => ({ usePrimaryAgentPresentation: () => primaryState }));
|
||||
const coordinatorState = vi.hoisted(() => ({ enabled: false }));
|
||||
vi.mock("../hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => coordinatorState }));
|
||||
|
||||
const dialogState = vi.hoisted(() => ({
|
||||
newIssueOpen: true,
|
||||
@@ -355,6 +357,7 @@ describe("NewIssueDialog", () => {
|
||||
let originalInnerHeightDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
coordinatorState.enabled = false;
|
||||
primaryState.primaryAgentId = null;
|
||||
primaryState.loading = false;
|
||||
vi.useRealTimers();
|
||||
@@ -1357,6 +1360,61 @@ describe("NewIssueDialog", () => {
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it.each([false, true])("creates a task from one description field (Coordinator %s)", async (enabled) => {
|
||||
coordinatorState.enabled = enabled;
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
expect(container.querySelector("#new-task-success-criteria")).toBeNull();
|
||||
expect(container.querySelectorAll("textarea")).toHaveLength(1);
|
||||
if (enabled) expect(container.textContent).toContain("New Task");
|
||||
const submit = container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!;
|
||||
expect(submit.disabled).toBe(true);
|
||||
await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Write the launch post");
|
||||
await waitForAssertion(() => expect(submit.disabled).toBe(false));
|
||||
await act(async () => submit.click());
|
||||
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(1));
|
||||
const payload = mockIssuesApi.create.mock.calls[0][1];
|
||||
expect(payload.description).toBe("Write the launch post");
|
||||
expect(payload).not.toHaveProperty("successCriteria");
|
||||
expect(payload).not.toHaveProperty("title");
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ enabled: false, description: "Write the launch post", criteria: " Ready for review ", expected: "Write the launch post\n\n## Success criteria\nReady for review" },
|
||||
{ enabled: true, description: "Write the launch post", criteria: " Ready for review ", expected: "Write the launch post\n\n## Success criteria\nReady for review" },
|
||||
{ enabled: true, description: "", criteria: "Ready for review", expected: "## Success criteria\nReady for review" },
|
||||
{ enabled: true, description: "Write the launch post", criteria: " \n ", expected: "Write the launch post" },
|
||||
])("restores legacy draft criteria into the editable description (Coordinator $enabled, criteria '$criteria')", async ({ enabled, description, criteria, expected }) => {
|
||||
coordinatorState.enabled = enabled;
|
||||
localStorage.setItem("paperclip:issue-draft:user-1", JSON.stringify({
|
||||
title: "", description, successCriteria: criteria, status: "todo", priority: "medium",
|
||||
assigneeValue: "", reviewerValue: "", approverValue: "", projectId: "",
|
||||
assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false,
|
||||
}));
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
expect(container.querySelector("#new-task-success-criteria")).toBeNull();
|
||||
const descriptionInput = container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!;
|
||||
expect(descriptionInput.value).toBe(expected);
|
||||
const editedDescription = `${expected}\nKeep this edit.`;
|
||||
await typeTextareaValue(descriptionInput, editedDescription);
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 850)); });
|
||||
const savedDraft = JSON.parse(localStorage.getItem("paperclip:issue-draft:user-1")!);
|
||||
expect(savedDraft.description).toBe(editedDescription);
|
||||
expect(savedDraft).not.toHaveProperty("successCriteria");
|
||||
await act(async () => root.unmount());
|
||||
const reopened = renderDialog(container);
|
||||
await flush();
|
||||
expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!.value).toBe(editedDescription);
|
||||
const submit = container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!;
|
||||
await waitForAssertion(() => expect(submit.disabled).toBe(false));
|
||||
await act(async () => submit.click());
|
||||
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(1));
|
||||
expect(mockIssuesApi.create.mock.calls[0][1].description).toBe(editedDescription);
|
||||
await act(async () => reopened.root.unmount());
|
||||
});
|
||||
|
||||
it.each(["Corrected task title", ""])("lets users edit or clear an inherited title (%s)", async (nextTitle) => {
|
||||
dialogState.newIssueDefaults = { title: "Suggested title", description: "Investigate the redirect" };
|
||||
const { root } = renderDialog(container);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../lib/project-workspace-defaults";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
import { useCoordinatorWorkspaceEnabled } from "../hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { getRecentAssigneeIds, getRecentAssigneeSelectionIds, sortAgentsByRecency, trackRecentAssignee, trackRecentAssigneeUser } from "../lib/recent-assignees";
|
||||
import { getLastProjectId, getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||
import { recordRecentTask } from "../lib/recent-tasks";
|
||||
@@ -308,7 +309,15 @@ function loadDraft(owner: string | undefined): IssueDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(pendingSendKeyFor(DRAFT_KEY, owner));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as IssueDraft;
|
||||
const { successCriteria, ...draft } = JSON.parse(raw) as IssueDraft & { successCriteria?: string };
|
||||
// Keep text entered in the retired field visible and editable in the brief.
|
||||
const criteria = typeof successCriteria === "string" ? successCriteria.trim() : "";
|
||||
return {
|
||||
...draft,
|
||||
description: criteria
|
||||
? [draft.description, `## Success criteria\n${criteria}`].filter(Boolean).join("\n\n")
|
||||
: draft.description,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -457,6 +466,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean })
|
||||
const navigate = useNavigate();
|
||||
const { pushToast } = useToastActions();
|
||||
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||
const { enabled: coordinatorEnabled } = useCoordinatorWorkspaceEnabled();
|
||||
const [title, setTitle] = useState("");
|
||||
const [hasTitle, setHasTitle] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -1449,7 +1459,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean })
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
<TaskChatPresentationProvider mode={streamlinedUiEnabled ? "streamlined" : "production"}>
|
||||
<TaskChatPresentationProvider mode={streamlinedUiEnabled || coordinatorEnabled ? "streamlined" : "production"}>
|
||||
<TaskChatComposer
|
||||
workMode={workMode}
|
||||
onWorkModeChange={setWorkMode}
|
||||
@@ -1505,7 +1515,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean })
|
||||
runSettings: composerSettings,
|
||||
onRunSettingsChange: setComposerSettings,
|
||||
header:
|
||||
hasTitle || isSubIssueMode || durableCreate.pending || resendError ? (
|
||||
hasTitle || isSubIssueMode || coordinatorEnabled || durableCreate.pending || resendError ? (
|
||||
<div className="mb-3 flex flex-col gap-2 text-xs">
|
||||
{durableCreate.pending && durableCreate.status !== "sending" ? (
|
||||
<PendingSendNotice
|
||||
@@ -1565,6 +1575,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean })
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{coordinatorEnabled ? <p className="text-sm font-medium text-foreground">New Task</p> : null}
|
||||
</div>
|
||||
) : undefined,
|
||||
details: (
|
||||
|
||||
@@ -590,10 +590,10 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
// so "Continue" on step 1 creates the organization and lands on the agent
|
||||
// step with no mission question in between.
|
||||
|
||||
async function openStepOne() {
|
||||
async function openStepOne(savedOverrides: Record<string, unknown> = {}) {
|
||||
window.localStorage.setItem(
|
||||
ONBOARDING_STORAGE_KEY,
|
||||
JSON.stringify({ step: 1, companyName: "Initech" }),
|
||||
JSON.stringify({ step: 1, companyName: "Initech", ...savedOverrides }),
|
||||
);
|
||||
mockDialog.onboardingOptions = {};
|
||||
mockCompany.companies = [];
|
||||
@@ -621,6 +621,34 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("defers the native runner until the name-only setup has a chosen provider", async () => {
|
||||
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
|
||||
const { root } = await openStepOne({ adapterType: "codex_local", runner: "paperclip" });
|
||||
try {
|
||||
await clickByText((text) => text.startsWith("Continue"));
|
||||
const field = document.body.querySelector("#onboarding-agent-name") as HTMLInputElement;
|
||||
await act(async () => setControlledValue(field, "Chief of Staff"));
|
||||
await flushReact();
|
||||
await clickByText((text) => isArcPrimary(text));
|
||||
|
||||
expect(document.body.textContent).toContain("Connect a model");
|
||||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||||
const setup = mockAgentsApi.create.mock.calls[0][1];
|
||||
expect(setup).toMatchObject({ adapterType: "process", metadata: { setupAdapterPending: true } });
|
||||
expect(setup).not.toHaveProperty("runner");
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
|
||||
await clickByText((text) => /OpenAI.*Subscription/.test(text));
|
||||
for (let i = 0; i < 3; i++) await flushReact();
|
||||
expect(mockAgentsApi.update).toHaveBeenCalledWith(setup.setupId, expect.objectContaining({
|
||||
adapterType: "codex_local", runner: "paperclip", metadata: { setupAdapterPending: false },
|
||||
}), "company-new");
|
||||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("shows no environment-check card on the model step, and no Mission row on review", async () => {
|
||||
// Round-3 walk feedback: the adapter environment check still runs —
|
||||
// Connect probes before hiring and blocks on a fail — but its idle card
|
||||
|
||||
@@ -1342,7 +1342,11 @@ function OnboardingWizardInner({
|
||||
setSetupAgentId(id);
|
||||
return agentsApi.create(companyId, {
|
||||
setupId: id, name: agentName.trim() || AGENT_ROLE_LABELS[agentRole], appearance: agentAppearance,
|
||||
role: agentRole, adapterType: createAdapter, runner,
|
||||
role: agentRole, adapterType: createAdapter,
|
||||
// The name-only record is held in setup and has no selected harness yet.
|
||||
// Apply the runner only once a real source is chosen; sending a native
|
||||
// runner with the provisional process adapter is an invalid combination.
|
||||
...(sourceSelected ? { runner } : {}),
|
||||
adapterConfig: createAdapter === "paperclip_runner" ? { provider: "acpx", acpxAgent: "copilot" } : {},
|
||||
metadata: { setupAdapterPending: !sourceSelected },
|
||||
onboardingFirstAgent: true, runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
@@ -21,12 +22,13 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout
|
||||
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
|
||||
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||
const designSandbox = useDesignSandbox();
|
||||
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
|
||||
const { enabled: coordinatorWorkspaceEnabled } = useCoordinatorWorkspaceEnabled();
|
||||
const streamlinedTaskDetailLayout = (streamlinedUiEnabled || coordinatorWorkspaceEnabled) && taskDetailLayout;
|
||||
// Keep the zero-width host mounted while route data loads. Inserting an
|
||||
// already-open pane skips its width transition and snaps the thread sideways.
|
||||
const visible = panelVisible && panelContent != null;
|
||||
|
||||
if (classicTaskInterfaceEnabled) {
|
||||
if (classicTaskInterfaceEnabled && !coordinatorWorkspaceEnabled) {
|
||||
return (
|
||||
<aside
|
||||
inert={!visible}
|
||||
|
||||
@@ -19,6 +19,26 @@ vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperiment
|
||||
|
||||
afterEach(() => vi.resetAllMocks());
|
||||
|
||||
it.each([
|
||||
["/PAP/issues/PAP-1", true],
|
||||
["/PAP/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", true],
|
||||
["/PAP/issues/all", false],
|
||||
["/PAP/issues", false],
|
||||
["/PAP/chats/agent", false],
|
||||
["/PAP/apps", false],
|
||||
])("permits only a guarded task detail while the personal preference loads: %s", async (path, permitted) => {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<MemoryRouter initialEntries={[path]}><Routes>
|
||||
<Route element={<ShellConfigurationGate loaded={false} allowPendingTaskDetail />}>
|
||||
<Route path="*" element={<div>Guarded task content</div>} />
|
||||
</Route>
|
||||
</Routes></MemoryRouter>));
|
||||
expect(container.textContent).toBe(permitted ? "Guarded task content" : "Loading shell");
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each([false, true])("runs access checks while shell settings are pending (membership=%s)", async allowMembershipRequest => {
|
||||
let resolveSettings!: (value: unknown) => void;
|
||||
health.mockResolvedValue({ status: "ok", deploymentMode: "authenticated", bootstrapStatus: "ready" });
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Outlet } from "@/lib/router";
|
||||
import { Outlet, useLocation } from "@/lib/router";
|
||||
import { PaperclipLoading } from "./AnimatedPaperclipIcon";
|
||||
|
||||
/** Keep shell selection stable while the independent access gate loads. */
|
||||
export function ShellConfigurationGate({ loaded }: { loaded: boolean }) {
|
||||
return loaded ? <Outlet /> : <PaperclipLoading />;
|
||||
export function ShellConfigurationGate({ loaded, allowPendingTaskDetail = false }: { loaded: boolean; allowPendingTaskDetail?: boolean }) {
|
||||
const { pathname } = useLocation();
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const isTaskDetail = segments.length === 3 && segments[1] === "issues"
|
||||
&& !["all", "active", "backlog", "done", "recent"].includes(segments[2]!);
|
||||
// Task detail owns a second presentation gate. It can show its account-bound
|
||||
// title editor while identity loads, without exposing variant-specific tools.
|
||||
return loaded || (allowPendingTaskDetail && isTaskDetail) ? <Outlet /> : <PaperclipLoading />;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CoordinatorSidebar } from "./CoordinatorSidebar";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import {
|
||||
Inbox,
|
||||
ListChecks,
|
||||
@@ -51,6 +53,11 @@ import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
import { primarySidebarStyles } from "./primary-sidebar-styles";
|
||||
|
||||
export function Sidebar({ children }: { children?: ReactNode }) {
|
||||
const { enabled } = useCoordinatorWorkspaceEnabled();
|
||||
return enabled ? <CoordinatorSidebar>{children}</CoordinatorSidebar> : <ClassicSidebar>{children}</ClassicSidebar>;
|
||||
}
|
||||
|
||||
function ClassicSidebar({ children }: { children?: ReactNode }) {
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { enabled: agentChatEnabled } = useAgentChatEnabled();
|
||||
// Every labeled section is collapsible (session-scoped, default open) —
|
||||
|
||||
@@ -448,7 +448,7 @@ describe("SidebarAccountMenu", () => {
|
||||
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
||||
.toContain("rounded-xl");
|
||||
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
||||
.toContain("min-h-(--profile-popover-min-height)");
|
||||
.not.toContain("min-h-");
|
||||
expect(document.body.querySelector('a[href="/company/settings"]')?.className)
|
||||
.not.toContain("bg-muted");
|
||||
expect(document.body.textContent).not.toContain("Manage company and instance settings.");
|
||||
|
||||
@@ -174,7 +174,7 @@ export function SidebarAccountMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-border/50 px-3 py-2 dark:bg-muted">
|
||||
<div data-sidebar-account="" className="bg-border/50 px-3 py-2 dark:bg-muted">
|
||||
<div className={cn("flex items-center gap-0.5", !rail && "px-2")}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -199,7 +199,7 @@ export function SidebarAccountMenu({
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="min-h-(--profile-popover-min-height) w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
||||
className="w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
||||
>
|
||||
{/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */}
|
||||
<FlyoutHeader data-flyout-profile data-marquee-surface className="relative flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
|
||||
@@ -235,7 +235,7 @@ export function SidebarAccountMenu({
|
||||
</div>
|
||||
</FlyoutHeader>
|
||||
|
||||
<FlyoutOptions className="flex flex-1 flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
|
||||
<FlyoutOptions className="flex flex-col gap-0.5 border-t border-border p-2.5">
|
||||
<MenuAction
|
||||
label="Settings"
|
||||
icon={Settings}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Compact document affordance shared by saved plans and authored document links. */
|
||||
export function SidebarDocumentLink({ href, children, ariaLabel, testId, className, onPointerEnter, onFocus }: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
ariaLabel?: string;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
onPointerEnter?: () => void;
|
||||
onFocus?: () => void;
|
||||
}) {
|
||||
return <Link to={href} disableIssueQuicklook aria-label={ariaLabel} data-testid={testId}
|
||||
onPointerEnter={onPointerEnter} onFocus={onFocus}
|
||||
data-sidebar-document-link
|
||||
className={cn("inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-muted/25 px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}>
|
||||
<FileText className="size-4 shrink-0" aria-hidden />{children}
|
||||
</Link>;
|
||||
}
|
||||
@@ -24,6 +24,8 @@ interface StalledReviewActionsProps {
|
||||
* approve" is what every issue already does.
|
||||
*/
|
||||
reviewPolicy?: IssueReviewPolicy | null;
|
||||
/** Explicit task-level wording where a separate asset approval may already exist. */
|
||||
approveLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -49,6 +51,7 @@ export function StalledReviewActions({
|
||||
footerSlot,
|
||||
onResolved,
|
||||
reviewPolicy,
|
||||
approveLabel = "Approve",
|
||||
className,
|
||||
}: StalledReviewActionsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -158,7 +161,7 @@ export function StalledReviewActions({
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
Approve
|
||||
{approveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// @vitest-environment jsdom
|
||||
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 { TaskChatThread } from "./TaskChatThread";
|
||||
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { pendingSuggestedTasksInteraction } from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import type { LiveRunForIssue } from "@/api/heartbeats";
|
||||
import type { IssueChatLinkedRun } from "@/lib/issue-chat-messages";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
coordinator: true,
|
||||
transcripts: new Map<string, TranscriptEntry[]>(),
|
||||
handoffs: [] as ConversationTaskHandoff[],
|
||||
plan: null as IssueDocument | null,
|
||||
}));
|
||||
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
|
||||
useLiveRunTranscripts: () => ({ transcriptByRun: new Map(), isInitialHydrating: false }),
|
||||
}));
|
||||
vi.mock("@/components/transcript/useNativeRunTranscripts", () => ({
|
||||
useNativeRunTranscripts: () => ({ transcriptByRun: new Map(state.transcripts), errorsByRun: new Map(),
|
||||
terminalHydratedRunIds: new Set(state.transcripts.keys()) }),
|
||||
}));
|
||||
vi.mock("@/hooks/useIssuePlanDocument", () => ({ useIssuePlanDocument: () => ({ data: state.plan }) }));
|
||||
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }) }));
|
||||
vi.mock("@/hooks/useStreamlinedUiEnabled", () => ({ useStreamlinedUiEnabled: () => ({ enabled: true, loaded: true }) }));
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
|
||||
useCoordinatorWorkspaceEnabled: () => ({ enabled: state.coordinator, loaded: true, userId: "viewer" }),
|
||||
}));
|
||||
vi.mock("@/components/coordinator/CoordinatorChatTaskHandoffs", async importOriginal => ({
|
||||
...await importOriginal<typeof import("@/components/coordinator/CoordinatorChatTaskHandoffs")>(),
|
||||
useCoordinatorChatTaskHandoffs: () => ({ handoffs: state.coordinator ? state.handoffs : [], isSuccess: true, enabled: state.coordinator }),
|
||||
}));
|
||||
vi.mock("@/components/coordinator/CoordinatorChatDecisionReceipts", async importOriginal => ({
|
||||
...await importOriginal<typeof import("@/components/coordinator/CoordinatorChatDecisionReceipts")>(),
|
||||
useCoordinatorChatDecisionReceipts: () => ({ receipts: [], enabled: false }),
|
||||
}));
|
||||
vi.mock("@/components/coordinator/CoordinatorChatPendingQuestions", () => ({
|
||||
useCoordinatorChatPendingQuestions: () => ({ questions: [], enabled: false }),
|
||||
CoordinatorChatQuestionStatus: () => null,
|
||||
}));
|
||||
vi.mock("@/api/issues", async importOriginal => ({
|
||||
...await importOriginal<typeof import("@/api/issues")>(),
|
||||
issuesApi: { summaries: vi.fn(async () => []) },
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ pathname: "/issues/chat", hash: "", search: "", key: "test", state: null }),
|
||||
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => <a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/components/MarkdownEditor", () => ({
|
||||
MarkdownEditor: forwardRef(function MockMarkdownEditor({ value }: { value: string }, ref: ForwardedRef<unknown>) {
|
||||
useImperativeHandle(ref, () => ({ insertMarkdown: () => {}, focus: () => {} }));
|
||||
return <div>{value}</div>;
|
||||
}),
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let client: QueryClient;
|
||||
const at = (second: number) => `2026-10-26T12:00:${String(second).padStart(2, "0")}Z`;
|
||||
const sourceRun: IssueChatLinkedRun = {
|
||||
runId: "source-run", status: "succeeded", runtimeMode: "native", adapterType: "paperclip_runner",
|
||||
agentId: "agent", agentName: "Assistant", createdAt: at(0), startedAt: at(0), finishedAt: at(10),
|
||||
};
|
||||
const comment = (id: string, body: string, second: number, runId?: string) => ({
|
||||
id, body, companyId: "company", issueId: "chat", authorType: runId ? "agent" as const : "user" as const,
|
||||
authorAgentId: runId ? "agent" : null, authorUserId: runId ? null : "viewer", runId,
|
||||
createdByRunId: runId ?? null, presentation: null, metadata: null,
|
||||
createdAt: new Date(at(second)), updatedAt: new Date(at(second)),
|
||||
});
|
||||
const handoff = (id = "task", sourceRunId: string | null = "source-run", second = 3): ConversationTaskHandoff => ({
|
||||
id: `chat_handoff:${id}`, issueId: id, identifier: `PAPER-${id}`, title: `Created ${id}`, status: "todo",
|
||||
createdAt: at(second), sourceRunId, suggestionInteractionId: null,
|
||||
});
|
||||
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)),
|
||||
result: status === "accepted" ? { version: 1, createdTasks: [{ clientKey: "post", issueId: "suggested-task", identifier: "PAPER-101", title: "Suggested task" }] } : null,
|
||||
});
|
||||
beforeEach(() => {
|
||||
state.coordinator = true;
|
||||
state.handoffs = [];
|
||||
state.plan = null;
|
||||
state.transcripts.clear();
|
||||
state.transcripts.set("source-run", [
|
||||
{ kind: "assistant", channel: "progress", text: "Preparing the task.", ts: at(2) },
|
||||
{ kind: "thinking", text: "Check the requested scope.", ts: at(4) },
|
||||
{ kind: "assistant", channel: "final", text: "Your task is ready.", ts: at(9) },
|
||||
]);
|
||||
localStorage.clear();
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
});
|
||||
afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); vi.restoreAllMocks(); });
|
||||
async function paint(overrides: Partial<React.ComponentProps<typeof TaskChatThread>> = {}) {
|
||||
await act(() => root.render(<QueryClientProvider client={client}><ThemeProvider>
|
||||
<CoordinatorPresentation value={state.coordinator}>
|
||||
<TaskChatThread issueId="chat" conversationMode currentUserId="viewer" comments={[
|
||||
comment("final", "Your task is ready.", 9, "source-run"), comment("next", "A later question.", 20),
|
||||
]} linkedRuns={[sourceRun]} onAdd={async () => {}} {...overrides} />
|
||||
</CoordinatorPresentation>
|
||||
</ThemeProvider></QueryClientProvider>));
|
||||
}
|
||||
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("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,
|
||||
createdAt: new Date(at(2)), updatedAt: new Date(at(2)), updatedByAgentId: "agent" } as IssueDocument;
|
||||
const result = { ...state.plan, id: "concepts", key: "concepts", title: "Concept document", body: "The concepts.",
|
||||
createdAt: new Date(at(5)), updatedAt: new Date(at(5)) };
|
||||
await paint({ documents: [state.plan, result], conversationMode: false });
|
||||
const plan = host.querySelector('[data-testid="task-chat-plan-preview"]')!;
|
||||
const output = host.querySelector('a[href="#document-concepts"]')!;
|
||||
expect(plan).toBeTruthy();
|
||||
expect(output).toBeTruthy();
|
||||
before(plan, output);
|
||||
expect(host.querySelectorAll('[data-testid="task-chat-plan-preview"]')).toHaveLength(1);
|
||||
});
|
||||
it("puts direct task cards after their complete source reply, before a later conversation", async () => {
|
||||
state.handoffs = [handoff(), handoff("unknown", null, 5)];
|
||||
await paint();
|
||||
before(anchor("final"), anchor("chat_handoff:task"));
|
||||
before(anchor("chat_handoff:task"), anchor("next"));
|
||||
before(anchor("chat_handoff:unknown"), anchor("final"));
|
||||
expect(host.querySelectorAll('[data-thread-anchor="chat_handoff:task"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("puts cards after transcript-only responses and every segment of a steered turn", async () => {
|
||||
state.handoffs = [handoff()];
|
||||
await paint({ comments: [
|
||||
{ ...comment("steer", "Keep the task small.", 5), steeredIntoRunId: "source-run", conversationAnchorAt: at(5) },
|
||||
comment("next", "A later question.", 20),
|
||||
] });
|
||||
before(anchor("source-run:turn:1"), anchor("chat_handoff:task"));
|
||||
before(anchor("chat_handoff:task"), anchor("next"));
|
||||
expect(anchor("source-run:turn:1").textContent).toContain("Your task is ready.");
|
||||
});
|
||||
|
||||
it("keeps a suggestion in the same end-of-turn slot when acceptance creates its task", async () => {
|
||||
await paint({ interactions: [suggestion("pending")] });
|
||||
const card = anchor("interaction:suggestion");
|
||||
before(anchor("final"), card);
|
||||
before(card, anchor("next"));
|
||||
state.handoffs = [{ ...handoff("suggested-task"), suggestionInteractionId: "suggestion" }];
|
||||
await paint({ interactions: [suggestion("accepted")] });
|
||||
expect(anchor("interaction:suggestion")).toBe(card);
|
||||
expect(card.querySelector(".coordinator-created-task")).not.toBeNull();
|
||||
expect(host.querySelector('[data-thread-anchor="chat_handoff:suggested-task"]')).toBeNull();
|
||||
before(anchor("final"), card);
|
||||
before(card, anchor("next"));
|
||||
});
|
||||
|
||||
it("follows the live tail and stays after the final reply when the turn settles", async () => {
|
||||
state.handoffs = [handoff()];
|
||||
const live: LiveRunForIssue = { id: "source-run", agentId: "agent", agentName: "Assistant", adapterType: "paperclip_runner",
|
||||
runtimeMode: "native", status: "running", invocationSource: "on_demand", triggerDetail: null,
|
||||
createdAt: at(0), startedAt: at(0), finishedAt: null };
|
||||
await paint({ comments: [], linkedRuns: [{ ...sourceRun, status: "running", finishedAt: null }], liveRuns: [live] });
|
||||
before(host.querySelector('[data-testid="task-chat-live-transcript"]')!, anchor("chat_handoff:task"));
|
||||
await paint();
|
||||
before(anchor("final"), anchor("chat_handoff:task"));
|
||||
before(anchor("chat_handoff:task"), anchor("next"));
|
||||
expect(host.querySelectorAll('[data-thread-anchor="chat_handoff:task"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps classic suggestion chronology when Coordinator is disabled", async () => {
|
||||
state.coordinator = false;
|
||||
await paint({ interactions: [suggestion("accepted")] });
|
||||
before(anchor("interaction:suggestion"), anchor("final"));
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,10 @@ import { credentialAccessNotice } from "@/lib/credential-access-notice";
|
||||
import type { ActivityEvent, IssueQueuedCommentQueue, TaskBrowser } from "@paperclipai/shared";
|
||||
import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems";
|
||||
import { skillCreatedItems } from "@/components/task-chat/skill-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";
|
||||
import { CoordinatorChatQuestionStatus, useCoordinatorChatPendingQuestions } from "@/components/coordinator/CoordinatorChatPendingQuestions";
|
||||
import { requiresExecutionReconciliation } from "@paperclipai/shared";
|
||||
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
|
||||
import { TaskChatScrollReady, useTaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation";
|
||||
@@ -89,6 +93,7 @@ import {
|
||||
interactionThreadAnchorMs,
|
||||
isSuppressedThreadInteraction,
|
||||
} from "@/components/task-chat/interaction-thread-order";
|
||||
import { orderTaskCreationItems, taskCreationSourceRunId } from "@/components/task-chat/task-creation-order";
|
||||
import {
|
||||
interactionReplacesComposerSkip,
|
||||
shouldHideInteractionCard,
|
||||
@@ -496,7 +501,9 @@ function durableInputLabel(
|
||||
* folded row. flag-OFF remains byte-for-byte IssueChatThread.
|
||||
*/
|
||||
export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||
const coordinatorEnabled = useCoordinatorPresentation();
|
||||
const { enabled: streamlinedPreference } = useStreamlinedUiEnabled();
|
||||
const streamlinedUiEnabled = coordinatorEnabled || streamlinedPreference;
|
||||
const {
|
||||
initialHistoryPending = false,
|
||||
initialHistoryError = false,
|
||||
@@ -609,6 +616,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
: onRetryFailedRun;
|
||||
const canRetryFailedRun = Boolean(retryFailedRunHandler);
|
||||
const queryClient = useQueryClient();
|
||||
const decisionReceipts = useCoordinatorChatDecisionReceipts(issueId, conversationMode);
|
||||
const pendingQuestions = useCoordinatorChatPendingQuestions(issueId, conversationMode);
|
||||
const taskHandoffs = useCoordinatorChatTaskHandoffs(issueId, conversationMode === true);
|
||||
const liveHandoffIds = useLiveChatHandoffIds(`${currentUserId}:${issueId}`, taskHandoffs.handoffs, taskHandoffs.isSuccess);
|
||||
const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId);
|
||||
const createdSkillItems = useMemo(() => skillCreatedItems(props.creationActivity ?? []), [props.creationActivity]);
|
||||
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
|
||||
@@ -1134,7 +1145,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// Prefer a native review request's explicit run binding; while that request
|
||||
// is still being created, infer the native owner from the document timestamp.
|
||||
const planDocumentSourceRunId = useMemo(() => {
|
||||
if (!planDocument) return null;
|
||||
// Coordinator keeps saved plans in document chronology. A run may write
|
||||
// several documents (or use shell tools with no semantic write boundary);
|
||||
// attaching the plan to its last write can put it after the resulting work.
|
||||
if (!planDocument || coordinatorEnabled) return null;
|
||||
const candidates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -1191,7 +1205,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
toMs(b.startedAt ?? b.createdAt) - toMs(a.startedAt ?? a.createdAt),
|
||||
);
|
||||
return matchingRuns[0]?.id ?? null;
|
||||
}, [activeRun, interactions, linkedRuns, liveRuns, planDocument]);
|
||||
}, [activeRun, coordinatorEnabled, interactions, linkedRuns, liveRuns, planDocument]);
|
||||
|
||||
const planTurnItem = useMemo<TaskChatPlanDocumentItem | null>(() => {
|
||||
if (!planDocument || !planDocumentSourceRunId) return null;
|
||||
@@ -1517,7 +1531,27 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// double-rendering beside its own settled turn.
|
||||
const settledRunIds = new Set<string>();
|
||||
const settledReplyRunIds = new Set<string>();
|
||||
const sourceRunByItemId = new Map<string, string>();
|
||||
const commentsById = new Map(projectedComments.map(comment => [comment.id, comment]));
|
||||
for (const { item } of orderedEntries) {
|
||||
if (item.kind !== "message" || item.author !== "agent") continue;
|
||||
const comment = commentsById.get(item.id);
|
||||
const runId = comment?.createdByRunId ?? comment?.runId ?? comment?.derivedCreatedByRunId;
|
||||
if (runId) sourceRunByItemId.set(item.id, runId);
|
||||
}
|
||||
const entriesWithFailures = [...orderedEntries];
|
||||
for (const question of pendingQuestions.questions) {
|
||||
const id = `pending-question:${question.subject.id}`;
|
||||
entriesWithFailures.push({ id, ms: toMs(question.createdAt), order: 4, item: { id, kind: "pending_question", question } });
|
||||
}
|
||||
for (const receipt of decisionReceipts.receipts) {
|
||||
entriesWithFailures.push({ id: `decision-receipt:${receipt.id}`, ms: toMs(receipt.resolvedAt), order: 4,
|
||||
item: { id: `decision-receipt:${receipt.id}`, kind: "decision_receipt", receipt } });
|
||||
}
|
||||
for (const handoff of standaloneChatHandoffs(taskHandoffs.handoffs, interactions ?? [])) {
|
||||
entriesWithFailures.push({ id: handoff.id, ms: toMs(handoff.createdAt), order: 4,
|
||||
item: { id: handoff.id, kind: "task_handoff", handoff, animateEntry: liveHandoffIds.has(handoff.id) } });
|
||||
}
|
||||
// Settled turns for every terminal run whose transcript we have. Provider
|
||||
// commentary, grouped activity (including reasoning summaries), and
|
||||
// terminal request receipts remain in transcript order. Final text is
|
||||
@@ -1525,6 +1559,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// runs remain activity/waiting state and never occupy a final-response slot.
|
||||
const settledTurns: {
|
||||
turn: TaskChatTurnItem;
|
||||
sourceRunId: string;
|
||||
anchorCommentId: string | null;
|
||||
startMs: number;
|
||||
}[] = [];
|
||||
@@ -1837,6 +1872,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
parts: [{ entries, durationMs, failed }],
|
||||
});
|
||||
settledTurns.push({
|
||||
sourceRunId: source.id,
|
||||
turn: {
|
||||
id: turnId,
|
||||
kind: "turn",
|
||||
@@ -2023,6 +2059,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
],
|
||||
});
|
||||
settledTurns.push({
|
||||
sourceRunId: source.id,
|
||||
turn: {
|
||||
id: turnId,
|
||||
kind: "turn",
|
||||
@@ -2116,6 +2153,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
],
|
||||
});
|
||||
settledTurns.push({
|
||||
sourceRunId: liveRun.id,
|
||||
turn: {
|
||||
id: turnId,
|
||||
kind: "turn",
|
||||
@@ -2149,7 +2187,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
|
||||
const turnsByAnchor = new Map<string, TaskChatTurnItem[]>();
|
||||
const unanchored: { turn: TaskChatTurnItem; startMs: number }[] = [];
|
||||
for (const { turn, anchorCommentId, startMs } of settledTurns) {
|
||||
for (const { turn, sourceRunId, anchorCommentId, startMs } of settledTurns) {
|
||||
sourceRunByItemId.set(turn.id, sourceRunId);
|
||||
if (anchorCommentId) {
|
||||
const list = turnsByAnchor.get(anchorCommentId) ?? [];
|
||||
list.push(turn);
|
||||
@@ -2183,7 +2222,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return {
|
||||
items: prependIssueBrief(
|
||||
attachSettledTurns(
|
||||
coalesceSettledTurns(out, turnMergeMetaById),
|
||||
coalesceSettledTurns(
|
||||
coordinatorEnabled ? orderTaskCreationItems(out, sourceRunByItemId) : out,
|
||||
turnMergeMetaById,
|
||||
),
|
||||
turnMergeMetaById,
|
||||
),
|
||||
hasBrief,
|
||||
@@ -2193,6 +2235,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
};
|
||||
}, [
|
||||
orderedEntries,
|
||||
coordinatorEnabled,
|
||||
decisionReceipts.receipts,
|
||||
pendingQuestions.questions,
|
||||
taskHandoffs.handoffs,
|
||||
liveHandoffIds,
|
||||
props.interruption,
|
||||
statusRelevance,
|
||||
canRetryFailedRun,
|
||||
@@ -2278,6 +2325,17 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
: showSettlingTail
|
||||
? settlingRun!.id
|
||||
: null;
|
||||
// A running (or still settling) turn lives outside the chronological body.
|
||||
// Its task cards follow that same tail until the complete turn takes over.
|
||||
const [threadItems, tailTaskCreationItems] = useMemo<[TaskChatItem[], TaskChatItem[]]>(() => {
|
||||
if (!coordinatorEnabled || !tailRunId) return [items, []];
|
||||
const body: TaskChatItem[] = [];
|
||||
const tail: TaskChatItem[] = [];
|
||||
for (const item of items) {
|
||||
(taskCreationSourceRunId(item) === tailRunId ? tail : body).push(item);
|
||||
}
|
||||
return [body, tail];
|
||||
}, [items, coordinatorEnabled, tailRunId]);
|
||||
const tailStreaming = Boolean(liveRun);
|
||||
const tailRunSource = tailRunId
|
||||
? runs.find((run) => run.id === tailRunId)
|
||||
@@ -2533,6 +2591,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
(interaction) =>
|
||||
interaction.status === "pending" &&
|
||||
interaction.kind !== "connection_intent" &&
|
||||
!(coordinatorEnabled && interaction.kind === "suggest_tasks") &&
|
||||
!shouldHideInteractionCard(interaction),
|
||||
)
|
||||
.sort((left, right) => toMs(right.createdAt) - toMs(left.createdAt));
|
||||
@@ -2553,7 +2612,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [interactions, pendingRuntimeRequest]);
|
||||
}, [interactions, pendingRuntimeRequest, coordinatorEnabled]);
|
||||
const { dismissedQuestionIds: dismissedInputIds, dismissQuestion: dismissInput } = useDismissedTaskQuestions(issueId, currentUserId);
|
||||
const isInputDismissed = useCallback((input: PendingComposerInput) =>
|
||||
input.kind === "durable"
|
||||
@@ -3095,7 +3154,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{threadHeaderWithBlockers ? (
|
||||
{threadHeaderWithBlockers || pendingQuestions.isError || decisionReceipts.isError || decisionReceipts.hasNextPage || taskHandoffs.isError || taskHandoffs.hasNextPage ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4",
|
||||
@@ -3105,6 +3164,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
data-testid="task-chat-thread-header"
|
||||
>
|
||||
{threadHeaderWithBlockers}
|
||||
<CoordinatorChatQuestionStatus query={pendingQuestions} /><CoordinatorChatReceiptStatus query={decisionReceipts} />
|
||||
<CoordinatorChatHandoffStatus query={taskHandoffs} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
@@ -3123,9 +3184,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatThreadView
|
||||
items={items}
|
||||
items={threadItems}
|
||||
attachments={attachments}
|
||||
header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}</>}
|
||||
header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}<CoordinatorChatQuestionStatus query={pendingQuestions} /><CoordinatorChatReceiptStatus query={decisionReceipts} /><CoordinatorChatHandoffStatus query={taskHandoffs} /></>}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={renderBrief}
|
||||
renderMessageActions={renderMessageActions}
|
||||
@@ -3223,6 +3284,13 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{tailTaskCreationItems.map(item => (
|
||||
<div key={item.id} data-thread-anchor={item.id} data-thread-item-kind={item.kind} className="mt-4">
|
||||
{item.kind === "task_handoff"
|
||||
? <CoordinatorChatTaskHandoff handoff={item.handoff} animateEntry={item.animateEntry} />
|
||||
: item.kind === "interaction" ? renderInteraction(item) : null}
|
||||
</div>
|
||||
))}
|
||||
{bottomBlockerLinks}
|
||||
</>
|
||||
) : null
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, useNavigate } from "@/lib/router";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import type { DecisionOutcome } from "@/api/decisions";
|
||||
import type { DecisionHistoryItem } from "@paperclipai/shared";
|
||||
import { CoordinatorDecisionReceipt } from "./coordinator/CoordinatorDecisionReceipt";
|
||||
import { TaskDecisionRecord } from "./TaskDecisionRecord";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), resolver: vi.fn() }));
|
||||
vi.mock("@/api/decisions", () => ({ decisionsApi: { get: mocks.get } }));
|
||||
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: { issuePrefix: "ORG" } }) }));
|
||||
vi.mock("./DecisionResolver", () => ({ DecisionResolver: (props: { recordedDecision?: DecisionOutcome }) => {
|
||||
mocks.resolver(props);
|
||||
return <p>{props.recordedDecision?.executions[0]?.result?.summary as string}</p>;
|
||||
} }));
|
||||
const decision = {
|
||||
id: "decision-1", companyId: "company", originIssueId: "task-1", status: "decided",
|
||||
title: "Choose launch channels", body: "Start with the blog", chosenOptionId: "blog",
|
||||
options: [{ id: "blog", label: "Own blog first", effects: [] }],
|
||||
inputs: [{ id: "reason", label: "Reason" }], inputValues: { reason: "The draft is ready" },
|
||||
executions: [{ id: "effect-1", result: { summary: "Blog draft queued" } }],
|
||||
} as unknown as DecisionOutcome;
|
||||
let root: Root, host: HTMLDivElement, client: QueryClient;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); mocks.get.mockResolvedValue(decision);
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); client.clear(); host.remove(); });
|
||||
async function mount(hash: string, viewerId = "person") {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/ORG/issues/ORG-1${hash}`]}>
|
||||
<TaskDecisionRecord companyId="company" issueId="task-1" viewerId={viewerId} />
|
||||
</MemoryRouter></QueryClientProvider>));
|
||||
}
|
||||
it("opens a compact receipt in its task's named full record with recorded inputs and results", async () => {
|
||||
function Journey() {
|
||||
const navigate = useNavigate();
|
||||
return <><CoordinatorDecisionReceipt item={{ href: "/ORG/issues/ORG-1#decision-decision-1", label: "Own blog first" } as DecisionHistoryItem} onOpen={navigate} />
|
||||
<TaskDecisionRecord companyId="company" issueId="task-1" viewerId="person" /></>;
|
||||
}
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={["/ORG/decisions"]}><Journey /></MemoryRouter></QueryClientProvider>));
|
||||
expect(mocks.get).not.toHaveBeenCalled();
|
||||
await act(async () => host.querySelector("button")!.click());
|
||||
await vi.waitFor(() => expect(host.querySelector("#decision-decision-1")?.textContent).toContain("Blog draft queued"));
|
||||
expect(host.textContent).toContain("Own blog first");
|
||||
expect(host.textContent).toContain("The draft is ready");
|
||||
expect(mocks.get).toHaveBeenCalledWith("decision-1");
|
||||
expect(mocks.resolver).toHaveBeenLastCalledWith(expect.objectContaining({ recordedDecision: decision }));
|
||||
});
|
||||
it.each([{ companyId: "other" }, { originIssueId: "another-task" }])("does not display a decision for a mismatched context %j", async (override) => {
|
||||
mocks.get.mockResolvedValue({ ...decision, ...override });
|
||||
await mount("#decision-decision-1");
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("not available in this task"));
|
||||
expect(mocks.resolver).not.toHaveBeenCalled();
|
||||
expect(host.textContent).not.toContain("The draft is ready");
|
||||
});
|
||||
it("does not expose cached record text after a viewer change loses access", async () => {
|
||||
await mount("#decision-decision-1");
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("The draft is ready"));
|
||||
mocks.get.mockRejectedValue(new Error("Forbidden"));
|
||||
await mount("#decision-decision-1", "another-person");
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("Could not load this decision"));
|
||||
expect(host.textContent).not.toContain("The draft is ready");
|
||||
});
|
||||
it("refreshes the mounted record after company decision events invalidate its results", async () => {
|
||||
mocks.get.mockResolvedValue({ ...decision, executions: [] });
|
||||
await mount("#decision-decision-1");
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("The draft is ready"));
|
||||
expect(host.textContent).not.toContain("Blog draft queued");
|
||||
mocks.get.mockResolvedValue(decision);
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: ["decisions", "company"] }); });
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("Blog draft queued"));
|
||||
expect(mocks.get).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.resolver).toHaveBeenLastCalledWith(expect.objectContaining({ recordedDecision: decision }));
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { decisionsApi } from "@/api/decisions";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { taskChatHashAnchor } from "./task-chat/scroll-navigation";
|
||||
import { DecisionResolver } from "./DecisionResolver";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
/** A selected Decisions-v1 record belongs to its task, not a second history tab. */
|
||||
export function TaskDecisionRecord({ companyId, issueId, viewerId, agentMap }: {
|
||||
companyId: string; issueId: string; viewerId: string | null; agentMap?: Map<string, Agent>;
|
||||
}) {
|
||||
const { hash } = useLocation();
|
||||
const anchor = taskChatHashAnchor(hash);
|
||||
const decisionId = anchor?.startsWith("decision-") ? anchor.slice("decision-".length) : null;
|
||||
const record = useQuery({
|
||||
queryKey: queryKeys.decisions.taskRecord(companyId, issueId, viewerId, decisionId),
|
||||
queryFn: () => decisionsApi.get(decisionId!), enabled: !!decisionId, retry: false,
|
||||
});
|
||||
if (!decisionId) return null;
|
||||
const decision = record.data;
|
||||
const matchesTask = decision?.companyId === companyId && decision.originIssueId === issueId;
|
||||
const choice = decision?.options.find((option) => option.id === decision.chosenOptionId);
|
||||
return <section id={anchor!} aria-label="Decision record" className="space-y-3 rounded-xl border border-border bg-card p-4">
|
||||
<h2 className="text-sm font-medium">Decision record</h2>
|
||||
{record.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading decision…</p> : record.isError ? (
|
||||
<div role="alert" className="text-sm"><p>Could not load this decision.</p><Button size="sm" variant="ghost" onClick={() => void record.refetch()}>Retry</Button></div>
|
||||
) : !matchesTask ? <p role="alert" className="text-sm text-muted-foreground">This decision is not available in this task.</p> : <>
|
||||
{decision.status !== "open" ? <dl className="space-y-2 text-sm">
|
||||
<div><dt className="text-muted-foreground">Recorded choice</dt><dd>{choice?.label ?? (decision.chosenOptionId === "dismissed" ? "Dismissed" : decision.status)}</dd></div>
|
||||
{(decision.inputs ?? []).filter((input) => decision.inputValues?.[input.id] !== undefined).map((input) => <div key={input.id}>
|
||||
<dt className="text-muted-foreground">{input.label}</dt><dd className="whitespace-pre-wrap">{decision.inputValues![input.id]}</dd>
|
||||
</div>)}
|
||||
</dl> : null}
|
||||
<DecisionResolver companyId={companyId} decisionId={decisionId} agentMap={agentMap} initialDecision={decision}
|
||||
recordedDecision={decision.status === "open" ? undefined : decision} onResolved={() => { void record.refetch(); }} />
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function includeTaskListAncestors(items: readonly TaskListItem[], matches
|
||||
}
|
||||
|
||||
/** Opt-in collection built from the reviewed row, hierarchy and column primitives. */
|
||||
export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState }: {
|
||||
export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState }: {
|
||||
items: readonly TaskListItem[];
|
||||
allItems?: readonly TaskListItem[];
|
||||
initialColumns?: readonly TaskListColumnId[];
|
||||
@@ -45,6 +45,7 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask
|
||||
onCollapsedIdsChange?: (ids: ReadonlySet<string>) => void;
|
||||
groupBy?: TaskListGrouping;
|
||||
emptyState?: ReactNode;
|
||||
issueLinkState?: unknown;
|
||||
}) {
|
||||
const [columns, setColumns] = useState(initialColumns);
|
||||
const [columnOrder, setColumnOrder] = useState(allTaskListColumns);
|
||||
@@ -83,7 +84,7 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask
|
||||
return (
|
||||
<Fragment key={issue.id}>
|
||||
<IssueRow
|
||||
issue={issue} presentation="task" appearance="refreshed" density={density}
|
||||
issue={issue} presentation="task" appearance="refreshed" density={density} issueLinkState={issueLinkState}
|
||||
unreadState={item.unread ? "visible" : "hidden"}
|
||||
treeGuides={depth} chevronInGuide={depth > 0 && children.length > 0}
|
||||
leadingControl={children.length ? (
|
||||
|
||||
@@ -9,9 +9,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { taskListViews as views, type TaskListView } from "@/lib/task-list-views";
|
||||
|
||||
/** Canonical proposed task toolbar; review under Components / Toolbars / Tasks. */
|
||||
export function TaskListToolbar({ view, onViewChange, query, onQueryChange, searchRef, layout, onLayoutChange, allExpanded, onToggleExpanded, onNewTask, filters }: {
|
||||
export function TaskListToolbar({ view, onViewChange, viewOptions = views, query, onQueryChange, searchRef, layout, onLayoutChange, allExpanded, onToggleExpanded, onNewTask, filters }: {
|
||||
view: TaskListView;
|
||||
onViewChange: (view: TaskListView) => void;
|
||||
viewOptions?: readonly { value: TaskListView; label: string }[];
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
searchRef?: Ref<HTMLInputElement>;
|
||||
@@ -25,11 +26,11 @@ export function TaskListToolbar({ view, onViewChange, query, onQueryChange, sear
|
||||
return (
|
||||
<div data-canonical-toolbar="tasks" className="design-page-toolbar" role="toolbar" aria-label="Task list controls">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant="outline" className="design-toolbar-button design-view-trigger" aria-label={`Task view: ${views.find(option => option.value === view)!.label}`}>{views.find(option => option.value === view)!.label}<ChevronDown aria-hidden /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger asChild><Button variant="outline" className="design-toolbar-button design-view-trigger" aria-label={`Task view: ${viewOptions.find(option => option.value === view)!.label}`}>{viewOptions.find(option => option.value === view)!.label}<ChevronDown aria-hidden /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="task-list-options" aria-label="Task views">
|
||||
<DropdownMenuLabel asChild><FlyoutHeader className="task-list-options-heading">Views</FlyoutHeader></DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup asChild value={view} onValueChange={value => onViewChange(value as TaskListView)}><FlyoutOptions className="task-list-options-items">
|
||||
{views.map(option => <DropdownMenuRadioItem className="task-list-options-item" key={option.value} value={option.value}>{option.label}</DropdownMenuRadioItem>)}
|
||||
{viewOptions.map(option => <DropdownMenuRadioItem className="task-list-options-item" key={option.value} value={option.value}>{option.label}</DropdownMenuRadioItem>)}
|
||||
</FlyoutOptions></DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { attentionApi } from "@/api/attention";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { coordinatorAttentionQueryKey, pendingDecisionTaskIds } from "@/lib/coordinator-attention";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { ToolbarSearch } from "@/components/ToolbarSearch";
|
||||
|
||||
/** Distinct tasks from Needs You, not a second status-based attention guess. */
|
||||
export function CoordinatorAttentionTasks({ toolbar }: { toolbar: ReactNode }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const feed = useQuery({ queryKey: coordinatorAttentionQueryKey(selectedCompanyId!),
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
|
||||
const items = feed.data?.items ?? [];
|
||||
const ids = pendingDecisionTaskIds(items);
|
||||
const tasks = [...new Map(items.flatMap((item) => {
|
||||
const task = item.relatedIssue ?? (item.subject.kind === "issue" ? item.subject : null);
|
||||
return task && ids.has(task.id) ? [[task.id, task] as const] : [];
|
||||
})).values()].filter((task) => `${task.identifier ?? ""} ${task.title ?? ""}`.toLocaleLowerCase().includes(search.toLocaleLowerCase()));
|
||||
return <section className="space-y-5">
|
||||
<h1 className="text-2xl font-medium">Tasks</h1>
|
||||
<div className="flex flex-wrap items-center gap-3">{toolbar}<ToolbarSearch query={search} onQueryChange={setSearch} label="Search tasks needing attention" placeholder="Search tasks…" /></div>
|
||||
{feed.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p> : null}
|
||||
{feed.isError ? <div role="alert" className="space-y-2"><p>Tasks needing attention could not be loaded.</p><Button variant="outline" onClick={() => void feed.refetch()}>Retry</Button></div> : null}
|
||||
{!feed.isPending && !feed.isError && !tasks.length ? <p className="py-8 text-sm text-muted-foreground">{search ? "No tasks match this search." : "No tasks need a decision."}</p> : null}
|
||||
<div className="divide-y divide-border">{tasks.map((task) => <button key={task.id} type="button" disabled={!task.href} onClick={() => { if (task.href) navigate(task.href); }} className="flex w-full items-center gap-3 py-4 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<StatusIcon status={task.status ?? "todo"} /><span className="text-sm text-muted-foreground">{task.identifier}</span><span className="flex-1 text-sm">{task.title}</span><span className="text-sm text-muted-foreground">{items.filter((item) => item.relatedIssue?.id === task.id || item.subject.id === task.id).length} pending</span><ArrowUpRight className="size-4 text-muted-foreground" />
|
||||
</button>)}</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { DecisionHistoryItem, DecisionHistoryPage } from "@paperclipai/shared";
|
||||
import { ArrowUpRight, Check, MessageSquare, RotateCcw, X } from "lucide-react";
|
||||
import { api, detachInflightGet } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { CoordinatorRelativeTime } from "./CoordinatorRelativeTime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function useCoordinatorChatDecisionReceipts(issueId: string | null, conversationMode: boolean | undefined) {
|
||||
const { enabled, userId } = useCoordinatorWorkspaceEnabled();
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["issues", "decision-receipts", issueId, userId],
|
||||
enabled: enabled && !!issueId && !!userId && conversationMode === true,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam, signal }) => {
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (pageParam) params.set("cursor", pageParam);
|
||||
const path = `/issues/${issueId}/decision-receipts?${params}`;
|
||||
// React Query deduplicates by viewer; never join an older account's
|
||||
// URL-only HTTP request after its session has changed.
|
||||
detachInflightGet(path);
|
||||
return api.get<DecisionHistoryPage>(path, { signal, cache: "no-store" });
|
||||
},
|
||||
getNextPageParam: page => page.nextCursor,
|
||||
retry: false,
|
||||
});
|
||||
const accessUnavailable = !!query.error && "status" in query.error && [401, 403, 404].includes(Number(query.error.status));
|
||||
const receipts = useMemo(() => {
|
||||
if (!enabled || !conversationMode || accessUnavailable) return [];
|
||||
// Overlapping pages after a live invalidation represent the same event.
|
||||
return [...new Map((query.data?.pages ?? []).flatMap(page => page.items).map(item => [item.id, item])).values()];
|
||||
}, [enabled, conversationMode, query.data, accessUnavailable]);
|
||||
return { ...query, receipts, enabled: enabled && conversationMode === true };
|
||||
}
|
||||
|
||||
export function CoordinatorChatDecisionReceipt({ receipt }: { receipt: DecisionHistoryItem }) {
|
||||
const Icon = receipt.outcome === "answered" ? MessageSquare : receipt.outcome === "changes_requested" ? RotateCcw : receipt.outcome === "rejected" ? X : Check;
|
||||
return <Link to={receipt.href} className="coordinator-decision-receipt" data-testid="coordinator-chat-decision-receipt">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{receipt.label}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground"><span className="font-mono">{receipt.issueIdentifier ?? receipt.issueId}</span> · {receipt.title}</span>
|
||||
</span>
|
||||
<CoordinatorRelativeTime value={receipt.resolvedAt} className="shrink-0 text-xs text-muted-foreground" />
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</Link>;
|
||||
}
|
||||
|
||||
export function CoordinatorChatReceiptStatus({ query }: { query: ReturnType<typeof useCoordinatorChatDecisionReceipts> }) {
|
||||
if (!query.enabled) return null;
|
||||
if (query.isError) return <div role="status" className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Decision updates couldn’t load.</span><Button size="sm" variant="ghost" onClick={() => void query.refetch()}>Retry</Button>
|
||||
</div>;
|
||||
if (query.hasNextPage) return <Button size="sm" variant="ghost" disabled={query.isFetchingNextPage} onClick={() => void query.fetchNextPage()}>
|
||||
{query.isFetchingNextPage ? "Loading earlier decisions…" : "Load earlier decisions"}
|
||||
</Button>;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, type ReactNode } 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 { CoordinatorChatPendingQuestion, CoordinatorChatQuestionStatus, useCoordinatorChatPendingQuestions } from "./CoordinatorChatPendingQuestions";
|
||||
import { coordinatorTaskQuestion, coordinatorTaskQuestionAttention } from "@/fixtures/coordinatorTaskQuestion";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), detach: vi.fn(), list: vi.fn(), answer: vi.fn(), viewer: "user-board", enabled: true }));
|
||||
vi.mock("@/api/client", () => ({ api: { get: mocks.get }, detachInflightGet: mocks.detach }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { listInteractions: mocks.list, respondToInteraction: mocks.answer } }));
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: mocks.enabled, userId: mocks.viewer }) }));
|
||||
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a> }));
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let host: HTMLDivElement; let root: Root; let client: QueryClient;
|
||||
function Chat({ conversation = true }: { conversation?: boolean }) {
|
||||
const query = useCoordinatorChatPendingQuestions("chat", conversation);
|
||||
return <><CoordinatorChatQuestionStatus query={query} />{query.questions.map(item => <CoordinatorChatPendingQuestion key={item.id} item={item} />)}</>;
|
||||
}
|
||||
const flush = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
async function paint(conversation = true) {
|
||||
await act(() => root.render(<QueryClientProvider client={client}><TooltipProvider><ThemeProvider><Chat conversation={conversation} /></ThemeProvider></TooltipProvider></QueryClientProvider>));
|
||||
await flush(); await flush();
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); mocks.viewer = "user-board"; mocks.enabled = true;
|
||||
mocks.get.mockResolvedValue([coordinatorTaskQuestionAttention]); mocks.list.mockResolvedValue([coordinatorTaskQuestion]);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
});
|
||||
afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); });
|
||||
|
||||
describe("pending task questions in agent chat", () => {
|
||||
it("keeps Classic and ordinary task threads free of additional projection requests", async () => {
|
||||
await paint(false); expect(mocks.get).not.toHaveBeenCalled();
|
||||
mocks.enabled = false; await paint(); expect(mocks.get).not.toHaveBeenCalled();
|
||||
});
|
||||
it("answers the canonical task question and refreshes all decision surfaces", async () => {
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
await paint();
|
||||
expect(host.textContent).toContain("Mira needs your input on Write the launch post.");
|
||||
expect(host.querySelector("a")?.getAttribute("href")).toBe(`/issues/PAPER-102#interaction-${coordinatorTaskQuestion.id}`);
|
||||
const resolved = { ...coordinatorTaskQuestion, status: "answered", resolvedAt: new Date(), result: { version: 1, answers: [{ questionId: "audience", optionIds: ["operators"] }] } };
|
||||
mocks.answer.mockImplementation(async () => { mocks.list.mockResolvedValue([resolved]); mocks.get.mockResolvedValue([]); return resolved; });
|
||||
await act(() => host.querySelector<HTMLButtonElement>('[role="radio"]')!.click());
|
||||
await act(() => [...host.querySelectorAll("button")].find(button => button.textContent === "Confirm audience")!.click());
|
||||
await flush(); await flush();
|
||||
expect(mocks.answer).toHaveBeenCalledWith(coordinatorTaskQuestion.issueId, coordinatorTaskQuestion.id, expect.objectContaining({ answers: expect.any(Array) }));
|
||||
expect(host.querySelector("[data-testid=coordinator-chat-pending-question]")).toBeNull();
|
||||
for (const queryKey of [queryKeys.attention(coordinatorTaskQuestion.companyId), ["issues", "pending-questions"], ["issues", "decision-receipts"]]) expect(invalidate).toHaveBeenCalledWith({ queryKey });
|
||||
});
|
||||
it("drops cached questions after authorization loss and separates viewers", async () => {
|
||||
await paint(); expect(host.textContent).toContain("Who is the primary reader?");
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error("Forbidden"), { status: 403 }));
|
||||
await act(() => client.invalidateQueries({ queryKey: ["issues", "pending-questions"] })); await flush();
|
||||
expect(host.querySelector("[data-testid=coordinator-chat-pending-question]")).toBeNull();
|
||||
expect(host.textContent).toContain("Task questions couldn’t load.");
|
||||
mocks.viewer = "another-viewer"; mocks.get.mockResolvedValue([]); await paint();
|
||||
expect(host.textContent).not.toContain("Who is the primary reader?");
|
||||
expect(mocks.get).toHaveBeenCalledWith("/issues/chat/pending-questions", expect.objectContaining({ cache: "no-store" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AttentionItem } from "@paperclipai/shared";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { api, detachInflightGet } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { AttentionInteractionResolver } from "@/components/AttentionInteractionResolver";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const EMPTY_QUESTIONS: AttentionItem[] = [];
|
||||
|
||||
/** A read projection of the task's question, never a second conversation form. */
|
||||
export function useCoordinatorChatPendingQuestions(issueId: string | null, conversationMode: boolean | undefined) {
|
||||
const { enabled, userId } = useCoordinatorWorkspaceEnabled();
|
||||
const active = enabled && !!issueId && !!userId && conversationMode === true;
|
||||
const query = useQuery({
|
||||
queryKey: ["issues", "pending-questions", issueId, userId],
|
||||
enabled: active,
|
||||
queryFn: ({ signal }) => {
|
||||
const path = `/issues/${issueId}/pending-questions`;
|
||||
detachInflightGet(path);
|
||||
return api.get<AttentionItem[]>(path, { signal, cache: "no-store" });
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
const accessUnavailable = !!query.error && "status" in query.error && [401, 403, 404].includes(Number(query.error.status));
|
||||
return { ...query, enabled: active, questions: active && !accessUnavailable ? query.data ?? EMPTY_QUESTIONS : EMPTY_QUESTIONS };
|
||||
}
|
||||
|
||||
export function CoordinatorChatPendingQuestion({ item }: { item: AttentionItem }) {
|
||||
const { userId } = useCoordinatorWorkspaceEnabled();
|
||||
const task = item.relatedIssue;
|
||||
if (!task) return null;
|
||||
const href = `${(task.href ?? `/issues/${task.id}`).split("#")[0]}#interaction-${item.subject.id}`;
|
||||
return <section data-testid="coordinator-chat-pending-question" className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{item.originAgentName ?? "Your agent"} needs your input on {task.title ?? task.identifier ?? "this task"}.</p>
|
||||
<article className="coordinator-decision-card space-y-4 rounded-xl border border-border bg-card p-6">
|
||||
<AttentionInteractionResolver companyId={task.companyId} issueId={task.id} interactionId={item.subject.id} currentUserId={userId} presentation="coordinator-decision" footerSlot={<Link to={href} className="flex max-w-full items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<span className="shrink-0">{task.identifier ?? "Open task"}</span><span className="truncate">{task.title}</span><ArrowUpRight className="size-4 shrink-0" />
|
||||
</Link>} />
|
||||
|
||||
</article>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function CoordinatorChatQuestionStatus({ query }: { query: ReturnType<typeof useCoordinatorChatPendingQuestions> }) {
|
||||
if (!query.enabled || !query.isError) return null;
|
||||
return <div role="status" className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Task questions couldn’t load.</span><Button size="sm" variant="ghost" onClick={() => void query.refetch()}>Retry</Button>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
|
||||
type ChatReference = { label: string; href: string };
|
||||
export const CoordinatorChatReferences = createContext<ReadonlyMap<string, ChatReference>>(new Map());
|
||||
export const useCoordinatorChatReference = (reference: string) => useContext(CoordinatorChatReferences).get(reference.toLowerCase());
|
||||
|
||||
function ActiveChatReferences({ children }: { children: ReactNode }) {
|
||||
const { chats, agents } = useAgentChatNavigation();
|
||||
const references = useMemo(() => {
|
||||
const result = new Map<string, ChatReference>();
|
||||
for (const chat of chats.data ?? []) {
|
||||
const agent = agents.data?.find(candidate => candidate.id === chat.conversationAgentId);
|
||||
if (!agent) continue;
|
||||
const reference = { label: `Chat with ${agent.name}`, href: `/chats/${encodeURIComponent(agentRouteRef(agent))}` };
|
||||
result.set(chat.id.toLowerCase(), reference);
|
||||
if (chat.identifier) result.set(chat.identifier.toLowerCase(), reference);
|
||||
}
|
||||
return result;
|
||||
}, [chats.data, agents.data]);
|
||||
return <CoordinatorChatReferences.Provider value={references}>{children}</CoordinatorChatReferences.Provider>;
|
||||
}
|
||||
|
||||
/** Resolve labels once from the same person/company-scoped list used by navigation. */
|
||||
export function CoordinatorChatReferencesProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
||||
return enabled ? <ActiveChatReferences>{children}</ActiveChatReferences> : children;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { ConversationTaskHandoff, ConversationTaskHandoffPage, IssueThreadInteraction } from "@paperclipai/shared";
|
||||
import { api, detachInflightGet } from "@/api/client";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoordinatorCreatedTaskLink, CoordinatorTaskMotion } from "./CoordinatorTaskCommitment";
|
||||
|
||||
/** Bounded, viewer-scoped source-chat projection, shared by chat and its Tasks tab. */
|
||||
export function useCoordinatorChatTaskHandoffs(issueId: string | null, conversationMode = true) {
|
||||
const { enabled, userId } = useCoordinatorWorkspaceEnabled();
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["issues", "chat-handoffs", issueId, userId],
|
||||
enabled: enabled && !!issueId && !!userId && conversationMode,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam, signal }) => {
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (pageParam) params.set("cursor", pageParam);
|
||||
const path = `/issues/${issueId}/chat-handoffs?${params}`;
|
||||
// React Query deduplicates by viewer; never join an older account's
|
||||
// URL-only HTTP request after its session has changed.
|
||||
detachInflightGet(path);
|
||||
return api.get<ConversationTaskHandoffPage>(path, { signal, cache: "no-store" });
|
||||
},
|
||||
getNextPageParam: page => page.nextCursor,
|
||||
retry: false,
|
||||
});
|
||||
const accessUnavailable = !!query.error && "status" in query.error && [401, 403, 404].includes(Number(query.error.status));
|
||||
const handoffs = useMemo(() => {
|
||||
if (!enabled || !conversationMode || accessUnavailable) return [];
|
||||
return [...new Map((query.data?.pages ?? []).flatMap(page => page.items).map(item => [item.id, item])).values()];
|
||||
}, [enabled, conversationMode, query.data, accessUnavailable]);
|
||||
return { ...query, handoffs, enabled: enabled && conversationMode };
|
||||
}
|
||||
|
||||
export function standaloneChatHandoffs(handoffs: ConversationTaskHandoff[], interactions: IssueThreadInteraction[]) {
|
||||
const renderedSuggestions = new Set(interactions.filter(item => item.kind === "suggest_tasks" && item.status === "accepted").map(item => item.id));
|
||||
return handoffs.filter(item => !item.suggestionInteractionId || !renderedSuggestions.has(item.suggestionInteractionId));
|
||||
}
|
||||
|
||||
/** First load and loading older pages are historical, not new task events. */
|
||||
export function useLiveChatHandoffIds(scope: string, handoffs: ConversationTaskHandoff[], fetched: boolean) {
|
||||
const observed = useRef<{ scope: string; initialized: boolean; ids: Set<string>; live: Set<string>; newest: number }>({ scope, initialized: false, ids: new Set(), live: new Set(), newest: 0 });
|
||||
return useMemo(() => {
|
||||
if (observed.current.scope !== scope) observed.current = { scope, initialized: false, ids: new Set(), live: new Set(), newest: 0 };
|
||||
const state = observed.current;
|
||||
if (!fetched) return state.live;
|
||||
for (const handoff of handoffs) {
|
||||
if (state.initialized && !state.ids.has(handoff.id) && new Date(handoff.createdAt).getTime() > state.newest) state.live.add(handoff.id);
|
||||
state.ids.add(handoff.id);
|
||||
}
|
||||
state.newest = Math.max(state.newest, ...handoffs.map(item => new Date(item.createdAt).getTime()));
|
||||
state.initialized = true;
|
||||
return new Set(state.live);
|
||||
}, [scope, handoffs, fetched]);
|
||||
}
|
||||
|
||||
export function CoordinatorChatTaskHandoff({ handoff, animateEntry = false }: { handoff: ConversationTaskHandoff; animateEntry?: boolean }) {
|
||||
return <CoordinatorTaskMotion eventId={handoff.id} phase="created" createdAt={handoff.createdAt} animateCreatedEntry={animateEntry}>
|
||||
<CoordinatorCreatedTaskLink task={handoff} createdAt={handoff.createdAt} />
|
||||
</CoordinatorTaskMotion>;
|
||||
}
|
||||
|
||||
export function CoordinatorChatHandoffStatus({ query }: { query: ReturnType<typeof useCoordinatorChatTaskHandoffs> }) {
|
||||
if (!query.enabled) return null;
|
||||
if (query.isError) return <div role="status" className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Created tasks couldn’t load.</span><Button size="sm" variant="ghost" onClick={() => void query.refetch()}>Retry</Button>
|
||||
</div>;
|
||||
if (query.hasNextPage) return <Button size="sm" variant="ghost" disabled={query.isFetchingNextPage} onClick={() => void query.fetchNextPage()}>
|
||||
{query.isFetchingNextPage ? "Loading earlier tasks…" : "Load earlier tasks"}
|
||||
</Button>;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @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, describe, expect, it, vi } from "vitest";
|
||||
import type { ConversationTaskHandoff, DecisionHistoryItem, IssueThreadInteraction } from "@paperclipai/shared";
|
||||
import { CoordinatorChatDecisionReceipt, CoordinatorChatReceiptStatus, useCoordinatorChatDecisionReceipts } from "./CoordinatorChatDecisionReceipts";
|
||||
import { CoordinatorInteractionReceipt } from "./CoordinatorInteractionReceipt";
|
||||
import { standaloneChatHandoffs, useCoordinatorChatTaskHandoffs, useLiveChatHandoffIds } from "./CoordinatorChatTaskHandoffs";
|
||||
import { answeredAskUserQuestionsInteraction, pendingSuggestedTasksInteraction } from "@/fixtures/issueThreadInteractionFixtures";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), detach: vi.fn(), viewer: "viewer-a", enabled: true }));
|
||||
vi.mock("@/api/client", () => ({ api: { get: mocks.get }, detachInflightGet: mocks.detach }));
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: mocks.enabled, userId: mocks.viewer }) }));
|
||||
vi.mock("@/lib/router", () => ({ Link: ({ to, children, disableIssueQuicklook: _disableIssueQuicklook, ...props }: any) => <a href={to} {...props}>{children}</a> }));
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let host: HTMLDivElement; let root: Root; let client: QueryClient;
|
||||
beforeEach(() => {
|
||||
mocks.enabled = true; mocks.viewer = "viewer-a"; mocks.get.mockReset(); mocks.detach.mockReset();
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); });
|
||||
const receipt = (id: string): DecisionHistoryItem => ({
|
||||
id, sourceKind: "issue_thread_interaction", sourceId: id, issueId: "task-id", issueIdentifier: "PAPER-102", issueTitle: "Write the post",
|
||||
agentId: "writer", agentName: "Mira", projectId: null, projectName: null, title: "Blog draft", outcome: "accepted", label: "Approved · v3",
|
||||
resolvedAt: "2026-10-26T12:00:00Z", resolvedByUserId: "viewer-a", resolvedByAgentId: null,
|
||||
href: `/issues/PAPER-102#document-plan&revision=exact-revision&interaction=${id}`, target: { kind: "issue_document", key: "plan", revisionId: "exact-revision" },
|
||||
});
|
||||
const handoff = (id: string, date: string, suggestionInteractionId: string | null = null): ConversationTaskHandoff => ({
|
||||
id: `chat_handoff:${id}`, issueId: id, identifier: `PAPER-${id}`, title: "Write the post", status: "todo", createdAt: date, sourceRunId: null, suggestionInteractionId,
|
||||
});
|
||||
const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
function Receipts({ conversation = true }: { conversation?: boolean }) {
|
||||
const query = useCoordinatorChatDecisionReceipts("chat-id", conversation);
|
||||
const tasks = useCoordinatorChatTaskHandoffs("chat-id", conversation);
|
||||
return <><CoordinatorChatReceiptStatus query={query} />{query.receipts.map(item => <CoordinatorChatDecisionReceipt key={item.id} receipt={item} />)}<span data-tasks>{tasks.handoffs.length}</span></>;
|
||||
}
|
||||
async function paint(conversation = true) { await act(() => root.render(<QueryClientProvider client={client}><Receipts conversation={conversation} /></QueryClientProvider>)); await settle(); }
|
||||
|
||||
describe("Coordinator source-chat updates", () => {
|
||||
it("does not fetch task/chat projections outside an opted-in standing conversation", async () => {
|
||||
await paint(false); expect(mocks.get).not.toHaveBeenCalled();
|
||||
mocks.enabled = false; await paint(true); expect(mocks.get).not.toHaveBeenCalled();
|
||||
});
|
||||
it("loads older receipts without duplicates and links the exact recorded revision", async () => {
|
||||
mocks.get.mockImplementation(async (url: string) => url.includes("chat-handoffs") ? { items: [], nextCursor: null } : url.includes("cursor=") ? { items: [receipt("second"), receipt("first")], nextCursor: null } : { items: [receipt("first")], nextCursor: "older" });
|
||||
await paint();
|
||||
expect(host.querySelector<HTMLAnchorElement>("a")?.getAttribute("href")).toBe(receipt("first").href);
|
||||
await act(() => [...host.querySelectorAll("button")].find(button => button.textContent === "Load earlier decisions")!.click()); await settle();
|
||||
expect(host.querySelectorAll("[data-testid=coordinator-chat-decision-receipt]")).toHaveLength(2);
|
||||
expect(host.textContent).not.toContain("Load earlier decisions");
|
||||
expect(client.getQueryData(["issues", "decision-receipts", "chat-id", "viewer-a"])).toBeDefined();
|
||||
expect(mocks.detach).toHaveBeenCalledWith("/issues/chat-id/decision-receipts?limit=100");
|
||||
expect(mocks.get).toHaveBeenCalledWith("/issues/chat-id/chat-handoffs?limit=100", expect.objectContaining({ cache: "no-store" }));
|
||||
mocks.viewer = "viewer-b"; mocks.get.mockImplementation(async () => ({ items: [], nextCursor: null })); await paint();
|
||||
expect(host.querySelectorAll("[data-testid=coordinator-chat-decision-receipt]")).toHaveLength(0);
|
||||
});
|
||||
it("only suppresses native handoffs whose accepted suggestion is already rendered", () => {
|
||||
const suggestion = { ...pendingSuggestedTasksInteraction, status: "accepted" } as IssueThreadInteraction;
|
||||
const records = [handoff("1", "2026-10-26T12:00:00Z", suggestion.id), handoff("2", "2026-10-26T12:01:00Z"), handoff("3", "2026-10-26T12:02:00Z", "unloaded-older-suggestion")];
|
||||
expect(standaloneChatHandoffs(records, [suggestion]).map(item => item.issueId)).toEqual(["2", "3"]);
|
||||
});
|
||||
it("animates new live handoffs without replaying history, older pages, or navigation", async () => {
|
||||
function Observation({ rows, scope }: { rows: ConversationTaskHandoff[]; scope: string }) {
|
||||
const live = useLiveChatHandoffIds(scope, rows, true);
|
||||
return <span>{[...live].join(",")}</span>;
|
||||
}
|
||||
const current = handoff("2", "2026-10-26T12:00:00Z");
|
||||
const older = handoff("1", "2026-10-25T12:00:00Z");
|
||||
const added = handoff("3", "2026-10-26T12:01:00Z");
|
||||
await act(() => root.render(<Observation rows={[current]} scope="first" />)); expect(host.textContent).toBe("");
|
||||
await act(() => root.render(<Observation rows={[current, older]} scope="first" />)); expect(host.textContent).toBe("");
|
||||
await act(() => root.render(<Observation rows={[added, current, older]} scope="first" />)); expect(host.textContent).toBe(added.id);
|
||||
await act(() => root.render(<Observation rows={[added, current, older]} scope="second" />)); expect(host.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors a local answered question to its answer without leaving the chat", async () => {
|
||||
const interaction = answeredAskUserQuestionsInteraction;
|
||||
const answer = document.createElement("div");
|
||||
answer.id = `comment-interaction-response:${interaction.id}`;
|
||||
const scroll = vi.fn(); answer.scrollIntoView = scroll;
|
||||
host.append(answer);
|
||||
await act(() => root.render(<CoordinatorInteractionReceipt interaction={interaction} />));
|
||||
// Root rendering replaces host children, so the transcript target is a sibling.
|
||||
document.body.append(answer);
|
||||
const link = host.querySelector("a")!;
|
||||
expect(link.getAttribute("href")).toBe(`#${answer.id}`);
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
await act(() => { link.dispatchEvent(event); });
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(scroll).toHaveBeenCalledWith({ block: "start", behavior: "auto" });
|
||||
answer.remove();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCoordinatorChatTaskHandoffs } from "./CoordinatorChatTaskHandoffs";
|
||||
import { CoordinatorCreatedTaskLink } from "./CoordinatorTaskCommitment";
|
||||
import { ToolbarSearch } from "@/components/ToolbarSearch";
|
||||
import { NativeSelect } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** The task list is source-conversation scoped, independent of reassignment. */
|
||||
export function CoordinatorConversationTasksPanel({ conversationIssueId }: { conversationIssueId: string }) {
|
||||
const tasks = useCoordinatorChatTaskHandoffs(conversationIssueId);
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [sort, setSort] = useState("newest");
|
||||
const visible = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
return tasks.handoffs.filter(task => (!status || task.status === status) && (!needle || `${task.identifier ?? ""} ${task.title}`.toLocaleLowerCase().includes(needle)))
|
||||
.sort((a, b) => sort === "title" ? a.title.localeCompare(b.title) : (sort === "oldest" ? 1 : -1) * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()));
|
||||
}, [tasks.handoffs, query, status, sort]);
|
||||
return <section className="space-y-4" aria-label="Tasks from this conversation">
|
||||
<div className="space-y-1"><h2 className="text-lg font-medium">Tasks</h2><p className="text-sm text-muted-foreground">Created from this conversation.</p></div>
|
||||
<ToolbarSearch query={query} onQueryChange={setQuery} label="Search conversation tasks" placeholder="Search tasks or IDs…" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<NativeSelect value={status} onChange={event => setStatus(event.target.value)} aria-label="Filter conversation tasks by status" className="w-auto"><option value="">All statuses</option><option value="todo">Ready to start</option><option value="in_progress">In progress</option><option value="in_review">Needs review</option><option value="blocked">Blocked</option><option value="done">Done</option><option value="cancelled">Cancelled</option></NativeSelect>
|
||||
<NativeSelect value={sort} onChange={event => setSort(event.target.value)} aria-label="Sort conversation tasks" className="w-auto"><option value="newest">Newest first</option><option value="oldest">Oldest first</option><option value="title">Title</option></NativeSelect>
|
||||
</div>
|
||||
{tasks.isPending && <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p>}
|
||||
{tasks.isError && <div role="alert" className="space-y-2"><p className="text-sm">Tasks couldn’t load.</p><Button variant="outline" onClick={() => void tasks.refetch()}>Retry</Button></div>}
|
||||
<div className="space-y-3">{visible.map(task => <div key={task.id} className="overflow-hidden rounded-xl border border-border bg-card"><CoordinatorCreatedTaskLink task={task} /></div>)}</div>
|
||||
{!tasks.isPending && !tasks.isError && !visible.length && <p className="py-4 text-sm text-muted-foreground">{query || status ? "No loaded tasks match these filters." : "Tasks created from this conversation will appear here."}</p>}
|
||||
{tasks.hasNextPage && <Button variant="outline" disabled={tasks.isFetchingNextPage} onClick={() => void tasks.fetchNextPage()}>{tasks.isFetchingNextPage ? "Loading…" : "Load more tasks"}</Button>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ArrowUpRight, Check } from "lucide-react";
|
||||
import type { DecisionHistoryItem } from "@paperclipai/shared";
|
||||
|
||||
/** A recorded decision links to its context; it is not a second approval form. */
|
||||
export function CoordinatorDecisionReceipt({ item, onOpen }: { item: DecisionHistoryItem; onOpen: (href: string) => void }) {
|
||||
return <button type="button" onClick={() => onOpen(item.href)} className="coordinator-decision-receipt flex w-full items-center gap-3 rounded-xl border border-border bg-card p-4 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Check className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="block text-sm font-medium">{item.label}</span>
|
||||
<span className="block truncate text-sm text-muted-foreground">{item.issueIdentifier} · {item.issueTitle}</span>
|
||||
</span>
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ArrowUpRight, Check, MessageSquare, X } from "lucide-react";
|
||||
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
|
||||
import { Link } from "@/lib/router";
|
||||
import { CoordinatorRelativeTime } from "./CoordinatorRelativeTime";
|
||||
import { decisionReceiptHref } from "@/lib/decision-receipt-navigation";
|
||||
|
||||
/** Compact notification; the original request and its full result stay in the task. */
|
||||
export function CoordinatorInteractionReceipt({ interaction }: { interaction: IssueThreadInteraction }) {
|
||||
const target = "target" in interaction.payload ? interaction.payload.target : null;
|
||||
const accepted = interaction.status === "accepted";
|
||||
const answered = interaction.status === "answered";
|
||||
const label = answered ? "Answers recorded" : accepted ? "Approved" : interaction.status === "rejected" ? "Changes requested" : interaction.status.replaceAll("_", " ");
|
||||
const Icon = answered ? MessageSquare : accepted ? Check : X;
|
||||
const answerAnchor = `comment-interaction-response:${interaction.id}`;
|
||||
const href = answered ? `#${answerAnchor}` : decisionReceiptHref({ issueHref: `/issues/${interaction.issueId}`, interactionId: interaction.id, target });
|
||||
return <Link to={href} disableIssueQuicklook onClick={answered ? (event) => {
|
||||
const answer = document.getElementById(answerAnchor);
|
||||
if (answer) {
|
||||
event.preventDefault();
|
||||
answer.scrollIntoView({ block: "start", behavior: "auto" });
|
||||
}
|
||||
} : undefined} className="coordinator-decision-receipt" data-testid="coordinator-decision-receipt">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{label}{target?.type === "issue_document" && target.revisionNumber ? ` · v${target.revisionNumber}` : ""}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{interaction.title ?? target?.label ?? "View decision in task"}</span>
|
||||
</span>
|
||||
{interaction.resolvedAt ? <CoordinatorRelativeTime value={interaction.resolvedAt} className="shrink-0 text-xs text-muted-foreground" /> : null}
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</Link>;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, type ReactNode } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
|
||||
import { CoordinatorNeedsYou } from "./CoordinatorNeedsYou";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const fixture = vi.hoisted(() => ({ pending: [] as AttentionItem[], history: [] as DecisionHistoryItem[], url: "/decisions", mobile: false, partial: false, historyFail: false, feedFail: false, openPanel: vi.fn(), closePanel: vi.fn(), setPanelVisible: vi.fn(), navigate: vi.fn(), breadcrumbs: vi.fn() }));
|
||||
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) }));
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: fixture.breadcrumbs }) }));
|
||||
vi.mock("@/context/PanelContext", () => ({ usePanel: () => ({ openPanel: fixture.openPanel, closePanel: fixture.closePanel, setPanelVisible: fixture.setPanelVisible }) }));
|
||||
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: fixture.mobile }) }));
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ userId: "reviewer" }) }));
|
||||
vi.mock("@/lib/router", async importOriginal => ({ ...await importOriginal<typeof import("@/lib/router")>(), useNavigate: () => fixture.navigate }));
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } }));
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
|
||||
vi.mock("@/api/attention", () => ({ attentionApi: {
|
||||
list: async () => { if (fixture.feedFail) throw new Error("Feed unavailable"); return { items: [...fixture.pending] }; },
|
||||
history: async () => { if (fixture.historyFail) throw new Error("History unavailable"); return { items: [...fixture.history], nextCursor: null }; },
|
||||
} }));
|
||||
vi.mock("motion/react", () => ({
|
||||
LayoutGroup: ({ children }: { children: ReactNode }) => children,
|
||||
useReducedMotion: () => true,
|
||||
motion: { article: ({ children, className }: { children: ReactNode; className: string }) => <article className={className}>{children}</article>, div: ({ children, id, tabIndex }: { children: ReactNode; id?: string; tabIndex?: number }) => <div id={id} tabIndex={tabIndex}>{children}</div> },
|
||||
}));
|
||||
vi.mock("@/components/PropertiesPanel", () => ({ PROPERTIES_PANE_HEADER_SLOT_ID: "test-sidebar" }));
|
||||
vi.mock("@/components/DecisionResolver", () => ({ DecisionResolver: () => null }));
|
||||
vi.mock("@/components/StalledReviewActions", () => ({ StalledReviewActions: ({ issueId, approveLabel, reviewPolicy, onResolved }: { issueId: string; approveLabel: string; reviewPolicy: string; onResolved: () => void }) => <button data-review-policy={reviewPolicy} onClick={() => {
|
||||
fixture.pending = fixture.pending.filter((item) => item.subject.id !== issueId);
|
||||
onResolved();
|
||||
}}>{approveLabel}</button> }));
|
||||
vi.mock("@/components/AttentionInteractionResolver", () => ({ AttentionInteractionResolver: ({ issueId, interactionId, footerSlot, onResolved }: { issueId: string; interactionId: string; footerSlot?: ReactNode; onResolved: (interaction: { status: string }) => void }) => <>{footerSlot}<button data-issue-id={issueId} onClick={() => {
|
||||
if (fixture.partial) { onResolved({ status: "pending" }); return; }
|
||||
fixture.pending = fixture.pending.filter((item) => item.subject.id !== interactionId);
|
||||
fixture.history.push({ id: `issue_thread_interaction:${interactionId}`, sourceKind: "issue_thread_interaction", sourceId: interactionId,
|
||||
issueId: `task-${interactionId}`, issueIdentifier: `PAP-${interactionId}`, issueTitle: `Task ${interactionId}`, title: "Decision", label: interactionId === "3" ? "Answers recorded" : "Approved", outcome: interactionId === "3" ? "answered" : "accepted", resolvedAt: new Date().toISOString(), resolvedByUserId: "reviewer", resolvedByAgentId: null, agentId: null, agentName: null, projectId: null, projectName: null, href: `/PAP/issues/PAP-${interactionId}#interaction-${interactionId}`, target: null });
|
||||
onResolved({ status: "answered" });
|
||||
}}>Resolve {interactionId}</button></> }));
|
||||
|
||||
function Location() { return <output data-location>{useLocation().search}</output>; }
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
fixture.pending = ["1", "2", "3"].map((id) => ({ id, sourceKind: "issue_thread_interaction", inlineResolvable: true, subject: { id, kind: "interaction", title: `Review ${id}`, metadata: { issueId: `task-${id}` } }, relatedIssue: { id: `task-${id}`, identifier: `PAP-${id}`, title: `Task ${id}`, href: `/PAP/issues/PAP-${id}` }, activityAt: new Date().toISOString() } as unknown as AttentionItem));
|
||||
fixture.history = []; fixture.url = "/decisions"; vi.clearAllMocks();
|
||||
fixture.historyFail = false; fixture.feedFail = false; fixture.partial = false; fixture.mobile = false;
|
||||
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||
async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); }
|
||||
async function click(text: string) {
|
||||
const button = [...container.querySelectorAll("button")].find((entry) => entry.textContent?.includes(text));
|
||||
expect(button, text).toBeTruthy();
|
||||
await act(async () => button!.click()); await flush();
|
||||
}
|
||||
describe("Coordinator Needs You", () => {
|
||||
it("reveals and focuses a linked decision beyond the first rendering window, then consumes only its URL parameter", async () => {
|
||||
fixture.url = "/decisions?decisionId=target&q=kept";
|
||||
fixture.pending = Array.from({ length: 61 }, (_, index) => ({ ...fixture.pending[0], id: `decision-${index}`, sourceKind: "decision",
|
||||
subject: { id: index === 60 ? "target" : `decision-${index}`, kind: "decision", title: `Decision ${index}` },
|
||||
activityAt: new Date(Date.now() - index * 1000).toISOString(),
|
||||
})) as AttentionItem[];
|
||||
const scroll = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, writable: true, value: scroll });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush(); await flush();
|
||||
const card = container.querySelector("#coordinator-decision-target");
|
||||
expect(card).not.toBeNull();
|
||||
expect(document.activeElement).toBe(card);
|
||||
expect(scroll).toHaveBeenCalledWith({ block: "center", behavior: "auto" });
|
||||
expect(container.querySelector("[data-location]")?.textContent).toBe("?q=kept");
|
||||
// Consumed links must not refocus on an ordinary feed refresh.
|
||||
scroll.mockClear();
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: ["attention"] }); }); await flush();
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
client.clear();
|
||||
});
|
||||
it("opens searchable history from both phone buttons without the hidden desktop panel", async () => {
|
||||
fixture.mobile = true;
|
||||
fixture.pending = [];
|
||||
fixture.history = [{ id: "old-decision", sourceKind: "issue_thread_interaction", sourceId: "old", issueId: "task-1", issueIdentifier: "PAP-1", issueTitle: "Earlier launch", title: "Review", label: "Approved", outcome: "accepted", resolvedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), resolvedByUserId: "reviewer", resolvedByAgentId: null, agentId: null, agentName: null, projectId: null, projectName: null, href: "/PAP/issues/PAP-1#interaction-old", target: null }];
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush();
|
||||
for (const label of ["Decision history", "Open decision history"]) {
|
||||
const trigger = [...container.querySelectorAll("button")].find(button => button.textContent?.trim() === label);
|
||||
expect(trigger).toBeTruthy();
|
||||
await act(async () => trigger!.click()); await flush();
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(dialog!.querySelector('[aria-label="Search decision history"]')).not.toBeNull();
|
||||
expect(dialog!.textContent).toContain("Earlier launch");
|
||||
expect(fixture.openPanel).not.toHaveBeenCalled();
|
||||
const close = [...dialog!.querySelectorAll("button")].find(button => button.textContent === "Close");
|
||||
await act(async () => close!.click()); await flush();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
}
|
||||
client.clear();
|
||||
});
|
||||
it("keeps a partial review actionable even when history is unavailable", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush();
|
||||
fixture.partial = true; fixture.historyFail = true;
|
||||
await click("Resolve 1"); await flush();
|
||||
expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resolve 1")).toBe(true);
|
||||
expect(container.querySelector('[data-motion="gather"]')).toBeNull();
|
||||
fixture.partial = false;
|
||||
await click("Resolve 1"); await flush();
|
||||
expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resolve 1")).toBe(false);
|
||||
client.clear();
|
||||
});
|
||||
it.each([false, true])("does not restore a saved decision when history refresh fails (stale feed: %s)", async staleFeed => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush();
|
||||
fixture.historyFail = true; fixture.feedFail = staleFeed;
|
||||
await click("Resolve 1"); await flush();
|
||||
expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resolve 1")).toBe(false);
|
||||
expect(container.textContent).toContain("Recent decisions could not be loaded");
|
||||
expect(container.textContent).toContain("Resolve 2");
|
||||
client.clear();
|
||||
});
|
||||
it("sorts, groups, and filters the recent decisions shown beneath the toolbar", async () => {
|
||||
fixture.pending = [];
|
||||
const now = Date.now();
|
||||
fixture.history = [
|
||||
{ id: "recent-old", sourceId: "old", issueIdentifier: "PAP-1", issueTitle: "First task", projectId: "launch", projectName: "Launch", resolvedAt: new Date(now - 180_000).toISOString() },
|
||||
{ id: "recent-middle", sourceId: "middle", issueIdentifier: "PAP-2", issueTitle: "Second task", projectId: "other", projectName: "Other", resolvedAt: new Date(now - 120_000).toISOString() },
|
||||
{ id: "recent-new", sourceId: "new", issueIdentifier: "PAP-3", issueTitle: "Third task", projectId: "launch", projectName: "Launch", resolvedAt: new Date(now - 60_000).toISOString() },
|
||||
].map((item) => ({ sourceKind: "issue_thread_interaction", issueId: item.sourceId, agentId: null, agentName: null,
|
||||
title: "Review", outcome: "accepted", label: "Approved", resolvedByUserId: "reviewer", resolvedByAgentId: null,
|
||||
href: `/PAP/issues/${item.issueIdentifier}`, target: null, ...item })) as DecisionHistoryItem[];
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush();
|
||||
const visibleIds = () => [...container.querySelectorAll('[aria-label="Recent Decisions"] .coordinator-decision-receipt')]
|
||||
.map((item) => item.textContent?.match(/PAP-\d+/)?.[0]);
|
||||
const changeSelect = async (label: string, value: string) => {
|
||||
const select = container.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`);
|
||||
expect(select).not.toBeNull();
|
||||
await act(async () => { select!.value = value; select!.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
};
|
||||
expect(visibleIds()).toEqual(["PAP-3", "PAP-2", "PAP-1"]);
|
||||
await changeSelect("Sort decisions", "oldest");
|
||||
expect(visibleIds()).toEqual(["PAP-1", "PAP-2", "PAP-3"]);
|
||||
await changeSelect("Group decisions", "task");
|
||||
expect([...container.querySelectorAll('[aria-label="Recent Decisions"] h3')].map((item) => item.textContent)).toEqual(["PAP-1", "PAP-2", "PAP-3"]);
|
||||
await changeSelect("Filter decisions by project", "launch");
|
||||
expect(visibleIds()).toEqual(["PAP-1", "PAP-3"]);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("resizes and gathers light in place before moving the saved receipt into Recent Decisions", async () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
const animate = vi.fn().mockReturnValue({ cancel: vi.fn() });
|
||||
HTMLElement.prototype.animate = animate;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
return { width: 500, height: this.querySelector("article") ? 320 : 80 } as DOMRect;
|
||||
});
|
||||
vi.spyOn(window, "getComputedStyle").mockReturnValue({ getPropertyValue: (name: string) => name.endsWith("ease") ? "ease-out" : name.includes("light") ? "1400ms" : name.includes("morph") ? "480ms" : "0ms" } as CSSStyleDeclaration);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(container.querySelector("[data-motion]")).toBeNull();
|
||||
vi.useFakeTimers();
|
||||
await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "Resolve 1")!.click());
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
||||
const transition = container.querySelector('[data-motion="gather"]');
|
||||
expect(transition?.textContent).toContain("Approved");
|
||||
expect(transition?.textContent).toContain("PAP-1");
|
||||
expect(container.querySelector('[aria-label="Recent Decisions"]')).toBeNull();
|
||||
expect(animate.mock.calls[0]?.[0]).toEqual([{ height: "320px", width: "500px" }, { height: "80px", width: "500px" }]);
|
||||
expect(animate.mock.calls[0]?.[1]).toMatchObject({ duration: 480 });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1400); });
|
||||
expect(container.querySelector("[data-motion]")).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Recent Decisions"]')?.textContent).toContain("PAP-1");
|
||||
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(1);
|
||||
client.clear();
|
||||
});
|
||||
it("renders a pending canonical question from persistent chat with its original resolver and context", async () => {
|
||||
fixture.pending = [{ id: "issue_thread_interaction:interaction:chat-question", sourceKind: "issue_thread_interaction", inlineResolvable: true,
|
||||
subject: { id: "chat-question", kind: "interaction", title: "Choose the screen", metadata: { kind: "ask_user_questions", issueId: "chat-issue" } },
|
||||
relatedIssue: { id: "chat-issue", identifier: "PAP-10", title: "Chat with Chief of Staff", href: "/PAP/issues/PAP-10" },
|
||||
detail: { kind: "questions", questionCount: 1, firstQuestionText: "Which screen should we redesign?", images: [] },
|
||||
activityAt: new Date().toISOString() } as unknown as AttentionItem];
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(container.querySelectorAll("article")).toHaveLength(1);
|
||||
expect(container.querySelector('[data-issue-id="chat-issue"]')?.textContent).toBe("Resolve chat-question");
|
||||
expect(container.textContent).toContain("Chat with Chief of Staff");
|
||||
expect(container.textContent).not.toContain("You're caught up.");
|
||||
await click("PAP-10");
|
||||
expect(fixture.navigate).toHaveBeenCalledWith("/PAP/issues/PAP-10");
|
||||
});
|
||||
it("keeps a distinct stalled task actionable after its document decision is recorded", async () => {
|
||||
fixture.pending = [{ id: "review:task-1", sourceKind: "review", inlineResolvable: true,
|
||||
subject: { id: "task-1", kind: "issue", title: "Launch copy", identifier: "PAP-1", href: "/PAP/issues/PAP-1", metadata: { reviewAttentionState: "stalled", reviewPolicy: "human_only" } },
|
||||
activityAt: new Date().toISOString() } as unknown as AttentionItem];
|
||||
fixture.history = [{ id: "issue_thread_interaction:approved-copy", sourceKind: "issue_thread_interaction", sourceId: "approved-copy", issueId: "task-1", issueIdentifier: "PAP-1", issueTitle: "Launch copy", title: "Copy review", label: "Approved", outcome: "accepted", resolvedAt: new Date().toISOString(), resolvedByUserId: "reviewer", resolvedByAgentId: null, agentId: null, agentName: null, projectId: null, projectName: null, href: "/PAP/issues/PAP-1#interaction-approved-copy", target: null }];
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Choose how this task continues");
|
||||
expect(container.textContent).not.toContain("Open to review");
|
||||
expect(container.querySelectorAll("article")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(1);
|
||||
expect(container.querySelector("[data-review-policy]")?.getAttribute("data-review-policy")).toBe("human_only");
|
||||
await click("Mark task done");
|
||||
expect(container.querySelectorAll("article")).toHaveLength(0);
|
||||
expect(container.textContent).toContain("You're caught up.");
|
||||
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(1);
|
||||
});
|
||||
it("retains all three decisions as receipts, including an answered question, and opens history only on demand", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
|
||||
await flush();
|
||||
expect(fixture.openPanel).not.toHaveBeenCalled();
|
||||
expect(container.querySelectorAll("article")).toHaveLength(3);
|
||||
await click("Resolve 1"); await click("Resolve 2"); await click("Resolve 3");
|
||||
await flush();
|
||||
expect(container.querySelectorAll("article")).toHaveLength(0);
|
||||
expect(container.textContent).toContain("Recent Decisions");
|
||||
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(3);
|
||||
expect(container.textContent).toContain("Answers recorded");
|
||||
await click("PAP-3");
|
||||
expect(fixture.navigate).toHaveBeenCalledWith("/PAP/issues/PAP-3#interaction-3");
|
||||
await click("Decision history");
|
||||
expect(fixture.openPanel).toHaveBeenCalled();
|
||||
expect(fixture.setPanelVisible).toHaveBeenCalledWith(true);
|
||||
// The shared desktop hide control changes visibility without closing
|
||||
// this tab. Clicking history again must reveal the already-open tab.
|
||||
fixture.setPanelVisible(false);
|
||||
fixture.setPanelVisible.mockClear();
|
||||
await click("Decision history");
|
||||
expect(fixture.setPanelVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpRight, History, Loader2 } from "lucide-react";
|
||||
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
|
||||
import type { Agent, AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
|
||||
import { attentionApi } from "@/api/attention";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { usePanel } from "@/context/PanelContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { coordinatorAttentionQueryKey, COORDINATOR_RECENT_DECISION_MS, recentDecisionReceipts } from "@/lib/coordinator-attention";
|
||||
import { attentionDetailLine, isInlineResolvable } from "@/lib/attention";
|
||||
import { AttentionInteractionResolver } from "@/components/AttentionInteractionResolver";
|
||||
import { StalledReviewActions } from "@/components/StalledReviewActions";
|
||||
import { DecisionResolver } from "@/components/DecisionResolver";
|
||||
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "@/components/PropertiesPanel";
|
||||
import { SidePanelTab } from "@/components/side-panel";
|
||||
import { ToolbarSearch } from "@/components/ToolbarSearch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NativeSelect } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { parseCssTimeMs } from "@/components/task-chat/motion-tokens";
|
||||
import { readIssueReviewPolicyMetadata } from "@/lib/review-policy";
|
||||
import { CoordinatorDecisionReceipt as Receipt } from "./CoordinatorDecisionReceipt";
|
||||
import { CoordinatorTaskMotion } from "./CoordinatorTaskCommitment";
|
||||
|
||||
type Group = "none" | "task" | "agent" | "project";
|
||||
|
||||
function HistoryPanel({ companyId, agents, onClose, mobile = false }: { companyId: string; agents: Agent[]; onClose: () => void; mobile?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [outcome, setOutcome] = useState("");
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [groupBy, setGroupBy] = useState<Group>("none");
|
||||
const [sort, setSort] = useState<"newest" | "oldest">("newest");
|
||||
const projects = useQuery({ queryKey: queryKeys.projects.list(companyId), queryFn: () => projectsApi.list(companyId) });
|
||||
const history = useInfiniteQuery({
|
||||
queryKey: [...queryKeys.attention(companyId), "history", { search, outcome, agentId, projectId, sort }],
|
||||
queryFn: ({ pageParam }) => attentionApi.history(companyId, { search, outcome, agentId, projectId, sort, cursor: pageParam, limit: 40 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (page) => page.nextCursor ?? undefined,
|
||||
});
|
||||
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
const groups = new Map<string, typeof items>();
|
||||
for (const item of items) {
|
||||
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle : groupBy === "agent" ? item.agentName ?? "Other agents" : groupBy === "project" ? item.projectName ?? "No project" : "";
|
||||
groups.set(label, [...(groups.get(label) ?? []), item]);
|
||||
}
|
||||
const slot = typeof document !== "undefined" ? document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID) : null;
|
||||
const tab = <SidePanelTab id="decision-history" label="Decision history" active onSelect={() => {}} onClose={onClose} />;
|
||||
return <div className="space-y-5" data-coordinator-decision-history>
|
||||
{!mobile ? <>{slot ? createPortal(tab, slot) : tab}<h2 className="text-lg font-medium">Decision history</h2></> : null}
|
||||
<ToolbarSearch query={search} onQueryChange={setSearch} label="Search decision history" placeholder="Search decisions or task IDs…" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NativeSelect aria-label="Filter decision outcomes" value={outcome} onChange={(event) => setOutcome(event.target.value)}>
|
||||
<option value="">All outcomes</option><option value="accepted">Approved</option><option value="answered">Answered</option><option value="rejected">Changes requested</option><option value="decided">Decided</option><option value="cancelled">Cancelled</option><option value="expired">Expired</option>
|
||||
</NativeSelect>
|
||||
<NativeSelect aria-label="Sort decision history" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="newest">Newest first</option><option value="oldest">Oldest first</option></NativeSelect>
|
||||
<NativeSelect aria-label="Filter decision history by agent" value={agentId} onChange={(event) => setAgentId(event.target.value)} className="col-span-2"><option value="">All agents</option>{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</NativeSelect>
|
||||
<NativeSelect aria-label="Filter decision history by project" value={projectId} onChange={(event) => setProjectId(event.target.value)} className="col-span-2"><option value="">All projects</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</NativeSelect>
|
||||
<NativeSelect aria-label="Group decision history" value={groupBy} onChange={(event) => setGroupBy(event.target.value as Group)} className="col-span-2"><option value="none">No grouping</option><option value="task">By task</option><option value="agent">By agent</option><option value="project">By project</option></NativeSelect>
|
||||
</div>
|
||||
{history.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading decisions…</p> : null}
|
||||
{history.isError ? <div role="alert" className="space-y-2 text-sm"><p>Decision history could not be loaded.</p><Button variant="outline" onClick={() => void history.refetch()}>Retry</Button></div> : null}
|
||||
{!history.isPending && !history.isError && !items.length ? <p className="text-sm text-muted-foreground">No decisions match these filters.</p> : null}
|
||||
<div className="space-y-4">{[...groups].map(([label, entries]) => <section key={label} className="space-y-3">{label ? <h3 className="text-sm font-medium text-muted-foreground">{label}</h3> : null}{entries.map((item) => <div key={item.id} className="space-y-1"><Receipt item={item} onOpen={navigate} /><p className="px-1 text-xs text-muted-foreground"><time dateTime={item.resolvedAt}>{new Date(item.resolvedAt).toLocaleString()}</time></p></div>)}</section>)}</div>
|
||||
{history.hasNextPage ? <Button variant="outline" disabled={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>{history.isFetchingNextPage ? "Loading…" : "Load more decisions"}</Button> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function CoordinatorNeedsYou() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { userId } = useCoordinatorWorkspaceEnabled();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { openPanel, closePanel, setPanelVisible } = usePanel();
|
||||
const { isMobile } = useSidebar();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deepLinkDecisionId = searchParams.get("decisionId");
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [query, setQuery] = useState("");
|
||||
const [agentFilter, setAgentFilter] = useState("");
|
||||
const [projectFilter, setProjectFilter] = useState("");
|
||||
const [groupBy, setGroupBy] = useState<Group>("none");
|
||||
const [sort, setSort] = useState<"newest" | "oldest">("newest");
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const [settling, setSettling] = useState<AttentionItem[]>([]);
|
||||
const [savedIds, setSavedIds] = useState<Set<string>>(() => new Set());
|
||||
useEffect(() => { setSettling([]); setSavedIds(new Set()); }, [selectedCompanyId, userId]);
|
||||
const [renderLimit, setRenderLimit] = useState(40);
|
||||
const feed = useQuery({ queryKey: coordinatorAttentionQueryKey(selectedCompanyId!),
|
||||
queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
|
||||
const agentQuery = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId });
|
||||
// Full history lives in its own paginated tab. Only the newest bounded page
|
||||
// is fetched here; its window expires visually without deleting any record.
|
||||
const recent = useQuery({ queryKey: [...queryKeys.attention(selectedCompanyId!), "history", "recent"],
|
||||
queryFn: () => attentionApi.history(selectedCompanyId!, { limit: 100, since: new Date(Date.now() - COORDINATOR_RECENT_DECISION_MS).toISOString() }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
|
||||
const agentMap = useMemo(() => new Map((agentQuery.data ?? []).map((agent) => [agent.id, agent])), [agentQuery.data]);
|
||||
const recentItems = useMemo(() => recentDecisionReceipts(recent.data?.items ?? [], now), [recent.data, now]);
|
||||
const settlingIds = useMemo(() => new Set(settling.map(item => `${item.sourceKind}:${item.subject.id}`)), [settling]);
|
||||
const visibleRecentItems = useMemo(() => recentItems.filter((item) => {
|
||||
if (settlingIds.has(item.id)) return false;
|
||||
if (agentFilter && item.agentId !== agentFilter) return false;
|
||||
if (projectFilter && item.projectId !== projectFilter) return false;
|
||||
return [item.title, item.issueTitle, item.issueIdentifier, item.agentName, item.projectName, item.label]
|
||||
.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase());
|
||||
}).sort((a, b) => (sort === "oldest" ? 1 : -1) * (Date.parse(a.resolvedAt) - Date.parse(b.resolvedAt))),
|
||||
[recentItems, settlingIds, agentFilter, projectFilter, query, sort]);
|
||||
const recentGroups = useMemo(() => {
|
||||
const result = new Map<string, DecisionHistoryItem[]>();
|
||||
for (const item of visibleRecentItems) {
|
||||
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle
|
||||
: groupBy === "agent" ? item.agentName ?? "Other agents"
|
||||
: groupBy === "project" ? item.projectName ?? "No project" : "";
|
||||
result.set(label, [...(result.get(label) ?? []), item]);
|
||||
}
|
||||
return [...result];
|
||||
}, [visibleRecentItems, groupBy]);
|
||||
const pendingItems = useMemo(() => {
|
||||
const receipts = new Set(recentItems.map((item) => item.id));
|
||||
const items = new Map((feed.data?.items ?? []).map((item) => [`${item.sourceKind}:${item.subject.id}`, item]));
|
||||
for (const item of settling) if (!items.has(`${item.sourceKind}:${item.subject.id}`)) items.set(`${item.sourceKind}:${item.subject.id}`, item);
|
||||
return [...items].filter(([key]) => settlingIds.has(key) || (!receipts.has(key) && !savedIds.has(key))).map(([, item]) => item);
|
||||
}, [feed.data, settling, settlingIds, recentItems, savedIds]);
|
||||
const filtered = useMemo(() => pendingItems.filter((item) => {
|
||||
const agentId = String(item.subject.metadata?.createdByAgentId ?? item.subject.metadata?.originAgentId ?? "");
|
||||
if (agentFilter && agentId !== agentFilter && item.originAgentName !== agentMap.get(agentFilter)?.name) return false;
|
||||
if (projectFilter && item.project?.id !== projectFilter) return false;
|
||||
return [item.subject.title, item.relatedIssue?.title, item.relatedIssue?.identifier, item.originAgentName, item.project?.name, attentionDetailLine(item)].join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase());
|
||||
}).sort((a, b) => (sort === "oldest" ? 1 : -1) * (Date.parse(a.activityAt) - Date.parse(b.activityAt))), [pendingItems, agentFilter, projectFilter, query, sort, agentMap]);
|
||||
const groups = useMemo(() => {
|
||||
const result = new Map<string, AttentionItem[]>();
|
||||
for (const item of filtered.slice(0, renderLimit)) {
|
||||
const label = groupBy === "task" ? item.relatedIssue?.identifier ?? item.relatedIssue?.title ?? "Other decisions"
|
||||
: groupBy === "agent" ? item.originAgentName ?? "Other agents" : groupBy === "project" ? item.project?.name ?? "No project" : "";
|
||||
result.set(label, [...(result.get(label) ?? []), item]);
|
||||
}
|
||||
return [...result];
|
||||
}, [filtered, groupBy, renderLimit]);
|
||||
useEffect(() => {
|
||||
if (!deepLinkDecisionId || !selectedCompanyId || !pendingItems.some(item => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId)) return;
|
||||
// A saved decision link takes precedence over local toolbar filters and the
|
||||
// rendering window. Consume it only after its actual card has mounted.
|
||||
if (query || agentFilter || projectFilter) { setQuery(""); setAgentFilter(""); setProjectFilter(""); return; }
|
||||
const index = filtered.findIndex(item => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId);
|
||||
if (index >= renderLimit) { setRenderLimit(Math.ceil((index + 1) / 40) * 40); return; }
|
||||
const card = document.getElementById(`coordinator-decision-${deepLinkDecisionId}`);
|
||||
if (!card) return;
|
||||
card.focus({ preventScroll: true });
|
||||
card.scrollIntoView({ block: "center", behavior: reducedMotion ? "auto" : "smooth" });
|
||||
setSearchParams(current => { const next = new URLSearchParams(current); next.delete("decisionId"); return next; }, { replace: true });
|
||||
}, [deepLinkDecisionId, selectedCompanyId, pendingItems, filtered, query, agentFilter, projectFilter, renderLimit, reducedMotion, setSearchParams]);
|
||||
const projects = useMemo(() => [...new Map([
|
||||
...pendingItems.flatMap((item) => item.project ? [[item.project.id, item.project.name] as const] : []),
|
||||
...recentItems.flatMap((item) => item.projectId ? [[item.projectId, item.projectName ?? "Unnamed project"] as const] : []),
|
||||
]).entries()], [pendingItems, recentItems]);
|
||||
const filtersActive = Boolean(query.trim() || agentFilter || projectFilter);
|
||||
const closeHistory = useCallback(() => { setHistoryOpen(false); closePanel(); }, [closePanel]);
|
||||
const showHistory = () => { setHistoryOpen(true); if (!isMobile) setPanelVisible(true); };
|
||||
useEffect(() => { setBreadcrumbs([{ label: "Needs You" }]); }, [setBreadcrumbs]);
|
||||
useEffect(() => {
|
||||
setSettling([]); setHistoryOpen(false); setNow(Date.now()); closePanel();
|
||||
return closePanel;
|
||||
}, [selectedCompanyId, closePanel]);
|
||||
useEffect(() => {
|
||||
if (!historyOpen || !selectedCompanyId || isMobile) { closePanel(); return; }
|
||||
setPanelVisible(true);
|
||||
openPanel(<HistoryPanel companyId={selectedCompanyId} agents={agentQuery.data ?? []} onClose={closeHistory} />);
|
||||
}, [historyOpen, selectedCompanyId, isMobile, agentQuery.data, openPanel, closePanel, closeHistory, setPanelVisible]);
|
||||
useEffect(() => {
|
||||
const next = recentItems.reduce((earliest, item) => Math.min(earliest, Date.parse(item.resolvedAt) + COORDINATOR_RECENT_DECISION_MS), Infinity);
|
||||
if (!Number.isFinite(next)) return;
|
||||
const timer = window.setTimeout(() => setNow(Date.now()), Math.max(0, next - Date.now()) + 1);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [recentItems]);
|
||||
const onResolved = (item: AttentionItem) => {
|
||||
const id = `${item.sourceKind}:${item.subject.id}`;
|
||||
setSavedIds(current => new Set([...current, id]));
|
||||
setSettling(current => current.some(entry => entry.id === item.id) ? current : [...current, item]);
|
||||
void recent.refetch().then(result => {
|
||||
if (result.isError) setSettling(current => current.filter(entry => `${entry.sourceKind}:${entry.subject.id}` !== id));
|
||||
});
|
||||
void feed.refetch();
|
||||
};
|
||||
const duration = typeof document === "undefined" ? 0 : parseCssTimeMs(getComputedStyle(document.documentElement).getPropertyValue("--motion-duration-base")) / 1_000;
|
||||
if (!selectedCompanyId) return <p className="text-sm text-muted-foreground">Choose a company to see its decisions.</p>;
|
||||
return <LayoutGroup id="coordinator-decisions"><section className="coordinator-needs-you mx-auto w-full max-w-4xl space-y-6" aria-label="Needs You">
|
||||
{isMobile ? <Sheet open={historyOpen} onOpenChange={open => { if (!open) closeHistory(); }}>
|
||||
<SheetContent side="bottom" aria-describedby={undefined} className="mobile-task-side-panel inset-0 h-dvh max-h-dvh w-full max-w-none gap-0 border-0 p-0 pt-(--sz-safe-top) pb-(--sz-safe-bottom)">
|
||||
<SheetHeader><SheetTitle>Decision history</SheetTitle></SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4"><HistoryPanel companyId={selectedCompanyId} agents={agentQuery.data ?? []} onClose={closeHistory} mobile /></div>
|
||||
</SheetContent>
|
||||
</Sheet> : null}
|
||||
<header className="flex items-center justify-between gap-3"><h1 className="text-2xl font-medium">Needs You</h1><Button variant="ghost" onClick={showHistory}><History className="size-4" />Decision history</Button></header>
|
||||
<div role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
|
||||
<ToolbarSearch query={query} onQueryChange={setQuery} label="Search decisions" placeholder="Search decisions or task IDs…" />
|
||||
<NativeSelect aria-label="Filter decisions by agent" value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} className="w-auto"><option value="">All agents</option>{(agentQuery.data ?? []).map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</NativeSelect>
|
||||
<NativeSelect aria-label="Filter decisions by project" value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)} className="w-auto"><option value="">All projects</option>{projects.map(([id, name]) => <option key={id} value={id}>{name}</option>)}</NativeSelect>
|
||||
<NativeSelect aria-label="Group decisions" value={groupBy} onChange={(event) => setGroupBy(event.target.value as Group)} className="w-auto"><option value="none">No grouping</option><option value="task">By task</option><option value="agent">By agent</option><option value="project">By project</option></NativeSelect>
|
||||
<NativeSelect aria-label="Sort decisions" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className="w-auto"><option value="newest">Newest first</option><option value="oldest">Oldest first</option></NativeSelect>
|
||||
</div>
|
||||
{feed.isPending ? <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading decisions…</p> : null}
|
||||
{feed.isError ? <div role="alert" className="space-y-2"><p className="text-sm">Decisions could not be loaded.</p><Button variant="outline" onClick={() => void feed.refetch()}>Retry</Button></div> : null}
|
||||
{!feed.isPending && !feed.isError && filtered.length === 0 ? <div className="space-y-3 py-8 text-center"><p className="text-sm text-muted-foreground">{filtersActive && !visibleRecentItems.length ? "No decisions match these filters." : pendingItems.length ? "No open decisions match these filters." : "You're caught up."}</p><Button variant="ghost" onClick={showHistory}>Open decision history<ArrowUpRight className="size-4" /></Button></div> : null}
|
||||
{groups.map(([label, items]) => <div key={label} className="space-y-4">{label ? <h2 className="text-sm font-medium text-muted-foreground">{label}</h2> : null}{items.map((item) => {
|
||||
const issueId = item.relatedIssue?.id ?? String(item.subject.metadata?.issueId ?? "");
|
||||
const interaction = item.sourceKind === "issue_thread_interaction" && isInlineResolvable(item) && issueId;
|
||||
const decision = item.sourceKind === "decision" && isInlineResolvable(item);
|
||||
const stalledReview = item.sourceKind === "review" && item.subject.kind === "issue" && isInlineResolvable(item) && item.subject.metadata?.reviewAttentionState === "stalled";
|
||||
const taskRef = item.relatedIssue ?? (item.subject.kind === "issue" ? item.subject : null);
|
||||
const taskLink = taskRef?.href ? <button type="button" onClick={() => { if (taskRef.href) navigate(taskRef.href); }} className="flex max-w-full items-center gap-2 text-left text-sm text-muted-foreground hover:text-foreground"><span className="shrink-0">{taskRef.identifier ?? "Open task"}</span><span className="truncate">{taskRef.title}</span><ArrowUpRight className="size-4 shrink-0" /></button> : null;
|
||||
const id = `${item.sourceKind}:${item.subject.id}`;
|
||||
const receipt = settlingIds.has(id) ? recentItems.find(entry => entry.id === id) : undefined;
|
||||
const phase = !receipt ? "pending" : ["cancelled", "expired"].includes(receipt.outcome) ? "closed" : "resolved";
|
||||
return <motion.div id={item.sourceKind === "decision" ? `coordinator-decision-${item.subject.id}` : undefined} tabIndex={item.sourceKind === "decision" ? -1 : undefined} layout={reducedMotion ? false : "position"} layoutId={reducedMotion ? undefined : id} transition={{ duration }} key={id}>
|
||||
<CoordinatorTaskMotion eventId={`decision:${selectedCompanyId}:${userId}:${id}`} phase={phase} createdAt={item.activityAt} className="coordinator-decision-motion"
|
||||
onSettled={() => setSettling(current => current.filter(entry => `${entry.sourceKind}:${entry.subject.id}` !== id))}>
|
||||
{receipt ? <Receipt item={receipt} onOpen={navigate} /> : <article className="coordinator-decision-card space-y-4 rounded-xl border border-border bg-card p-6">
|
||||
{interaction ? <AttentionInteractionResolver footerSlot={taskLink} presentation="coordinator-decision" companyId={selectedCompanyId} issueId={issueId} interactionId={item.subject.id} agentMap={agentMap} currentUserId={userId} onResolved={resolved => {
|
||||
if (!resolved || resolved.status === "pending") { void feed.refetch(); void recent.refetch(); return; }
|
||||
onResolved(item);
|
||||
}} />
|
||||
: decision ? <DecisionResolver companyId={selectedCompanyId} decisionId={item.subject.id} originIssue={item.relatedIssue} agentMap={agentMap} onResolved={() => onResolved(item)} />
|
||||
: stalledReview ? <><h2 className="text-lg font-medium">Choose how this task continues</h2><p className="text-sm">This task is still in review with no active reviewer or continuation. Return it to work, request changes, or mark the task done.</p><StalledReviewActions footerSlot={taskLink} issueId={item.subject.id} companyId={selectedCompanyId} reviewPolicy={readIssueReviewPolicyMetadata(item.subject.metadata)} approveLabel="Mark task done" onResolved={() => { void feed.refetch(); void recent.refetch(); }} /></>
|
||||
: <><h2 className="text-lg font-medium">{item.subject.title ?? item.whyNow}</h2><p className="text-sm">{attentionDetailLine(item) ?? item.whyNow}</p><Button disabled={!item.subject.href} onClick={() => { if (item.subject.href) navigate(item.subject.href); }}>Open to review<ArrowUpRight className="size-4" /></Button></>}
|
||||
{!interaction && !stalledReview ? taskLink : null}
|
||||
</article>}
|
||||
</CoordinatorTaskMotion>
|
||||
</motion.div>;
|
||||
})}</div>)}
|
||||
{filtered.length > renderLimit ? <Button variant="outline" onClick={() => setRenderLimit((value) => value + 40)}>Show more decisions</Button> : null}
|
||||
{visibleRecentItems.length ? <section className="space-y-3" aria-label="Recent Decisions"><h2 className="text-sm font-medium text-muted-foreground">Recent Decisions</h2>{recentGroups.map(([label, items]) => <div key={label} className="space-y-3">{label ? <h3 className="text-sm font-medium text-muted-foreground">{label}</h3> : null}{items.map((item) => <motion.div layout={!reducedMotion} layoutId={reducedMotion ? undefined : item.id} transition={{ duration }} key={item.id}><Receipt item={item} onOpen={navigate} /></motion.div>)}</div>)}</section> : null}
|
||||
{recent.isError ? <p role="alert" className="text-sm text-muted-foreground">Recent decisions could not be loaded. <button className="underline" onClick={() => void recent.refetch()}>Retry</button></p> : null}
|
||||
</section></LayoutGroup>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/** Presentation context is cheap enough to read in every transcript message.
|
||||
* The shell alone resolves the saved experimental preference. */
|
||||
export const CoordinatorPresentation = createContext(false);
|
||||
export const useCoordinatorPresentation = () => useContext(CoordinatorPresentation);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { coordinatorRelativeLabel } from "./CoordinatorRelativeTime";
|
||||
const now = Date.parse("2026-10-26T12:00:00Z");
|
||||
describe("Coordinator message time", () => {
|
||||
it("uses the durable timestamp and handles clock skew without future ages", () => {
|
||||
expect(coordinatorRelativeLabel("2026-10-26T11:58:00Z", now)).toBe("2m ago");
|
||||
expect(coordinatorRelativeLabel("2026-10-26T10:00:00Z", now)).toBe("2h ago");
|
||||
expect(coordinatorRelativeLabel("2026-10-25T12:00:00Z", now)).toBe("1d ago");
|
||||
expect(coordinatorRelativeLabel("2026-10-26T12:01:00Z", now)).toBe("Just now");
|
||||
});
|
||||
it("does not invent a time for an invalid timestamp", () => expect(coordinatorRelativeLabel("invalid", now)).toBeNull());
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let now = Date.now();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!timer) {
|
||||
now = Date.now();
|
||||
timer = setInterval(() => { now = Date.now(); listeners.forEach(notify => notify()); }, 30_000);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size && timer) { clearInterval(timer); timer = undefined; }
|
||||
};
|
||||
}
|
||||
export function useCoordinatorRelativeClock() {
|
||||
return useSyncExternalStore(subscribe, () => now, () => now);
|
||||
}
|
||||
export function coordinatorRelativeLabel(value: string | Date, clock: number) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const elapsed = Math.max(0, clock - timestamp);
|
||||
if (elapsed < 60_000) return "Just now";
|
||||
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`;
|
||||
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`;
|
||||
if (elapsed < 604_800_000) return `${Math.floor(elapsed / 86_400_000)}d ago`;
|
||||
return new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric", ...(new Date(value).getFullYear() !== new Date(clock).getFullYear() ? { year: "numeric" as const } : {}) });
|
||||
}
|
||||
export function CoordinatorRelativeTime({ value, className }: { value: string | Date; className?: string }) {
|
||||
const clock = useCoordinatorRelativeClock();
|
||||
const label = coordinatorRelativeLabel(value, clock);
|
||||
if (!label) return null;
|
||||
const date = new Date(value);
|
||||
const exact = date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" });
|
||||
return <time dateTime={date.toISOString()} title={exact} aria-label={exact} tabIndex={0} className={className}>{label}</time>;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CoordinatorTaskMotion } from "./CoordinatorTaskCommitment";
|
||||
import { TaskChatCompactInteractionCard } from "../task-chat/TaskChatCompactInteractionCard";
|
||||
import { pendingSuggestedTasksInteraction } from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true, loaded: true, userId: "viewer" }) }));
|
||||
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: any) => <a href={to} {...props}>{children}</a> }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { summaries: vi.fn(async () => [{ id: "task-created", identifier: "PAPER-108", title: "Write the launch post", status: "todo" }]) } }));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
let client: QueryClient;
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
});
|
||||
afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
function render(interaction: typeof pendingSuggestedTasksInteraction, onAcceptInteraction = vi.fn(), onRejectInteraction = vi.fn()) {
|
||||
return act(() => root.render(<QueryClientProvider client={client}><TooltipProvider>
|
||||
<TaskChatCompactInteractionCard interaction={interaction} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} />
|
||||
</TooltipProvider></QueryClientProvider>));
|
||||
}
|
||||
|
||||
describe("Coordinator task commitment", () => {
|
||||
it("does not claim creation when acceptance is still pending or failed", async () => {
|
||||
let fail!: (reason: Error) => void;
|
||||
const accept = vi.fn(() => new Promise<void>((_, reject) => { fail = reject; }));
|
||||
await render(pendingSuggestedTasksInteraction, accept);
|
||||
await act(() => [...host.querySelectorAll("button")].find(button => button.textContent === "Create")!.click());
|
||||
expect(accept).toHaveBeenCalledOnce();
|
||||
expect(host.querySelector(".coordinator-created-task")).toBeNull();
|
||||
await act(() => fail(new Error("Creation could not be saved")));
|
||||
expect(host.textContent).toContain("Creation could not be saved");
|
||||
expect(host.querySelector(".coordinator-created-task")).toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes rejection from revision and restores actions on cancel", async () => {
|
||||
const accept = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const button = (label: string) => [...host.querySelectorAll("button")].find(item => item.textContent === label)!;
|
||||
await render(pendingSuggestedTasksInteraction, accept, reject);
|
||||
expect(host.querySelector("h3")?.textContent).toBe("Suggested Task");
|
||||
expect(["Reject", "Revise…", "Create"].every(label => button(label))).toBe(true);
|
||||
await act(() => button("Revise…").click());
|
||||
expect(button("Create")).toBeUndefined();
|
||||
expect(button("Reject")).toBeUndefined();
|
||||
expect(button("Request revision").disabled).toBe(true);
|
||||
await act(() => button("Cancel").click());
|
||||
expect(button("Create")).toBeDefined();
|
||||
expect(reject).not.toHaveBeenCalled();
|
||||
await act(() => button("Revise…").click());
|
||||
const field = host.querySelector("textarea")!;
|
||||
await act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(field, "Start with a smaller draft");
|
||||
field.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(() => button("Request revision").click());
|
||||
expect(reject).toHaveBeenCalledWith(pendingSuggestedTasksInteraction, "Start with a smaller draft");
|
||||
expect(accept).not.toHaveBeenCalled();
|
||||
await act(() => button("Cancel").click());
|
||||
await act(() => button("Reject").click());
|
||||
expect(reject).toHaveBeenLastCalledWith(pendingSuggestedTasksInteraction, undefined);
|
||||
});
|
||||
|
||||
it("uses accepted server identities without navigating, then shows canonical current status", async () => {
|
||||
await render(pendingSuggestedTasksInteraction);
|
||||
await render({ ...pendingSuggestedTasksInteraction, status: "accepted", result: { version: 1, createdTasks: [{ clientKey: "launch", issueId: "task-created", identifier: "PAPER-108", title: "Write the launch post" }] } });
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
const link = host.querySelector<HTMLAnchorElement>(".coordinator-created-task");
|
||||
expect(link?.getAttribute("href")).toBe("/issues/PAPER-108");
|
||||
expect(link?.textContent).toContain("PAPER-108");
|
||||
expect(link?.textContent).toContain("Write the launch post");
|
||||
expect(link?.querySelector('[aria-label="Todo"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("starts resizing and light together only on the observed creation transition", async () => {
|
||||
const animation = { cancel: vi.fn() };
|
||||
const animate = vi.fn().mockReturnValue(animation);
|
||||
HTMLElement.prototype.animate = animate;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
const phase = (this as HTMLElement).parentElement?.dataset.phase;
|
||||
return { width: 500, height: phase === "created" ? 80 : 240 } as DOMRect;
|
||||
});
|
||||
vi.spyOn(window, "getComputedStyle").mockReturnValue({ getPropertyValue: (name: string) => name.endsWith("ease") ? "ease-out" : name.includes("light") ? "1400ms" : name.includes("morph") ? "480ms" : "880ms" } as CSSStyleDeclaration);
|
||||
const date = new Date(Date.now() + 1000).toISOString();
|
||||
const paint = (phase: "suggested" | "created") => act(() => root.render(<CoordinatorTaskMotion eventId="motion-live" createdAt={date} phase={phase}><span>{phase}</span></CoordinatorTaskMotion>));
|
||||
await paint("suggested");
|
||||
expect(host.firstElementChild?.getAttribute("data-motion")).toBe("arrive");
|
||||
await paint("created");
|
||||
expect(host.firstElementChild?.getAttribute("data-motion")).toBe("gather");
|
||||
expect(animate.mock.calls[0]?.[1]).toMatchObject({ duration: 480 });
|
||||
await act(() => root.render(null)); animate.mockClear();
|
||||
await paint("created");
|
||||
expect(host.firstElementChild?.hasAttribute("data-motion")).toBe(false);
|
||||
expect(animate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles immediately for reduced motion", async () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
await act(() => root.render(<CoordinatorTaskMotion eventId="reduced-motion" phase="suggested" createdAt={new Date()}><span>Task suggestion</span></CoordinatorTaskMotion>));
|
||||
expect(host.textContent).toContain("Task suggestion");
|
||||
expect(host.firstElementChild?.hasAttribute("data-motion")).toBe(false);
|
||||
});
|
||||
|
||||
it("settles saved decisions immediately for reduced motion and does not replay them on return", async () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
const animate = vi.fn();
|
||||
HTMLElement.prototype.animate = animate;
|
||||
const onSettled = vi.fn();
|
||||
const createdAt = new Date(0).toISOString();
|
||||
const paint = (phase: "pending" | "resolved") => act(() => root.render(
|
||||
<CoordinatorTaskMotion eventId="decision-reduced-motion" phase={phase} createdAt={createdAt} onSettled={onSettled}>
|
||||
<span>{phase}</span>
|
||||
</CoordinatorTaskMotion>,
|
||||
));
|
||||
await paint("pending");
|
||||
expect(onSettled).not.toHaveBeenCalled();
|
||||
await paint("resolved");
|
||||
expect(onSettled).toHaveBeenCalledOnce();
|
||||
expect(animate).not.toHaveBeenCalled();
|
||||
expect(host.firstElementChild?.hasAttribute("data-motion")).toBe(false);
|
||||
await act(() => root.render(null));
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
await paint("resolved");
|
||||
expect(onSettled).toHaveBeenCalledTimes(2);
|
||||
expect(animate).not.toHaveBeenCalled();
|
||||
expect(host.firstElementChild?.hasAttribute("data-motion")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpRight, FileCheck2 } from "lucide-react";
|
||||
import type { ConversationTaskHandoff } from "@paperclipai/shared";
|
||||
import type { SuggestTasksInteraction } from "@/lib/issue-thread-interactions";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Link } from "@/lib/router";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { CoordinatorRelativeTime } from "./CoordinatorRelativeTime";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
|
||||
// Presentation-only memory. It contains no task text or authority, and prevents
|
||||
// revisiting a conversation from replaying its task-creation celebration.
|
||||
const sessionStartedAt = Date.now();
|
||||
const presentedEvents = new Set<string>();
|
||||
|
||||
function milliseconds(value: string) {
|
||||
const number = Number.parseFloat(value);
|
||||
return Number.isFinite(number) ? number * (value.trim().endsWith("ms") ? 1 : 1000) : 0;
|
||||
}
|
||||
|
||||
/** One bounded shape changes while its contained light starts on the same frame.
|
||||
* The caller supplies canonical server state; animation never creates a task. */
|
||||
export function CoordinatorTaskMotion({
|
||||
eventId,
|
||||
phase,
|
||||
createdAt,
|
||||
animateCreatedEntry = false,
|
||||
className = "",
|
||||
onSettled,
|
||||
children,
|
||||
}: {
|
||||
eventId: string;
|
||||
phase: "suggested" | "created" | "pending" | "resolved" | "closed";
|
||||
createdAt: Date | string;
|
||||
animateCreatedEntry?: boolean;
|
||||
className?: string;
|
||||
onSettled?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const previous = useRef<{ phase: typeof phase; height: number; width: number } | null>(null);
|
||||
const incompleteEntrance = useRef(false);
|
||||
const clickedSize = useRef<{ height: number; width: number } | null>(null);
|
||||
const settledCallback = useRef(onSettled);
|
||||
settledCallback.current = onSettled;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!root || !content) return;
|
||||
const rect = content.getBoundingClientRect();
|
||||
const from = previous.current && { ...previous.current, ...clickedSize.current };
|
||||
const key = `${eventId}:${phase}`;
|
||||
const newSuggestion = !from && phase === "suggested" && new Date(createdAt).getTime() >= sessionStartedAt;
|
||||
const accepted = (from?.phase === "suggested" && phase === "created")
|
||||
|| (from?.phase === "pending" && phase === "resolved");
|
||||
previous.current = { phase, height: rect.height, width: rect.width };
|
||||
const liveCreation = !from && phase === "created" && animateCreatedEntry;
|
||||
const fresh = (newSuggestion || accepted || liveCreation) && !presentedEvents.has(key);
|
||||
if (fresh) presentedEvents.add(key);
|
||||
if (fresh) incompleteEntrance.current = true;
|
||||
const preference = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
const animations: Animation[] = [];
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const clear = () => {
|
||||
root.removeAttribute("data-motion");
|
||||
animations.forEach(animation => animation.cancel());
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
};
|
||||
const finish = () => {
|
||||
incompleteEntrance.current = false; clear();
|
||||
if (phase === "resolved" || phase === "closed") settledCallback.current?.();
|
||||
};
|
||||
const reduceMotion = () => { if (preference?.matches) finish(); };
|
||||
preference?.addEventListener("change", reduceMotion);
|
||||
if (preference?.matches) finish();
|
||||
else if (incompleteEntrance.current) {
|
||||
const tokens = getComputedStyle(root);
|
||||
const motion = phase === "created" || phase === "resolved" ? "gather" : "arrive";
|
||||
const duration = milliseconds(tokens.getPropertyValue(motion === "gather" ? "--coordinator-task-light-duration" : "--coordinator-task-arrive-duration"));
|
||||
if (duration > 0) {
|
||||
root.dataset.motion = motion;
|
||||
if (accepted && from.height > 0 && rect.height > 0 && typeof root.animate === "function") {
|
||||
const shapeDuration = milliseconds(tokens.getPropertyValue("--coordinator-task-morph-duration"));
|
||||
const easing = tokens.getPropertyValue("--coordinator-task-ease").trim();
|
||||
// Measured geometry only; visual dimensions and timing remain tokens.
|
||||
animations.push(root.animate([
|
||||
{ height: `${from.height}px`, width: `${from.width}px` },
|
||||
{ height: `${rect.height}px`, width: `${rect.width}px` },
|
||||
], { duration: shapeDuration, easing }));
|
||||
animations.push(content.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
duration: milliseconds(tokens.getPropertyValue("--coordinator-task-content-duration")), easing,
|
||||
}));
|
||||
}
|
||||
timeout = setTimeout(finish, duration);
|
||||
} else finish();
|
||||
} else if (phase === "resolved" || phase === "closed") finish();
|
||||
return () => {
|
||||
clear();
|
||||
preference?.removeEventListener("change", reduceMotion);
|
||||
};
|
||||
}, [eventId, phase, createdAt, animateCreatedEntry]);
|
||||
|
||||
return <div ref={rootRef} className={`coordinator-task-commitment ${className}`} data-phase={phase}
|
||||
onClickCapture={() => {
|
||||
// Capture the full form before a CTA starts saving. Resolver/history reads
|
||||
// may settle on different frames; don't morph from their interim layout.
|
||||
if (phase !== "pending" && phase !== "suggested") return;
|
||||
const rect = contentRef.current?.getBoundingClientRect();
|
||||
if (rect) clickedSize.current = { height: rect.height, width: rect.width };
|
||||
}}>
|
||||
<div ref={contentRef} className="coordinator-task-commitment-content">{children}</div>
|
||||
<span className="coordinator-task-light" aria-hidden="true" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
/** A server-confirmed task identity. This is also shared by native create_task
|
||||
* handoffs, so both creation paths settle into the same card. */
|
||||
export function CoordinatorCreatedTaskLink({ task, createdAt }: {
|
||||
createdAt?: Date | string | null;
|
||||
task: Pick<ConversationTaskHandoff, "issueId" | "identifier" | "title"> & { status?: ConversationTaskHandoff["status"] };
|
||||
}) {
|
||||
return <Link to={`/issues/${task.identifier ?? task.issueId}`} className="coordinator-created-task">
|
||||
{task.status ? <StatusIcon status={task.status} size="lg" /> : <FileCheck2 className="size-5 shrink-0 text-muted-foreground" aria-label="Task created" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{task.title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground"><span className="font-mono">{task.identifier ?? task.issueId}</span> · Created</span>
|
||||
</span>
|
||||
{createdAt ? <CoordinatorRelativeTime value={createdAt} className="shrink-0 text-xs text-muted-foreground" /> : null}
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</Link>;
|
||||
}
|
||||
|
||||
function CreatedTaskLinks({ interaction }: { interaction: SuggestTasksInteraction }) {
|
||||
const { userId } = useCoordinatorWorkspaceEnabled();
|
||||
const tasks = interaction.result?.createdTasks ?? [];
|
||||
const ids = tasks.map(task => task.issueId).sort();
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.issues.summaryBatch(interaction.companyId, userId, ids),
|
||||
queryFn: ({ signal }) => issuesApi.summaries(interaction.companyId, ids, { signal }),
|
||||
enabled: ids.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
if (tasks.length === 0) return <p className="p-4 text-sm text-muted-foreground">No tasks were created.</p>;
|
||||
return <div className="divide-y divide-border">
|
||||
{tasks.map(task => {
|
||||
const summary = query.data?.find(candidate => candidate.id === task.issueId);
|
||||
return <CoordinatorCreatedTaskLink key={task.issueId} createdAt={interaction.resolvedAt} task={{
|
||||
issueId: task.issueId,
|
||||
identifier: summary?.identifier ?? task.identifier ?? null,
|
||||
title: summary?.title ?? task.title ?? "Task created",
|
||||
status: summary?.status,
|
||||
}} />;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function CoordinatorTaskCommitment({ interaction, children }: { interaction: SuggestTasksInteraction; children: ReactNode }) {
|
||||
const phase = interaction.status === "pending" ? "suggested" : interaction.status === "accepted" ? "created" : "closed";
|
||||
return <CoordinatorTaskMotion eventId={`${interaction.companyId}:${interaction.id}`} phase={phase} createdAt={interaction.createdAt}>
|
||||
{phase === "created" ? <CreatedTaskLinks interaction={interaction} /> : children}
|
||||
</CoordinatorTaskMotion>;
|
||||
}
|
||||
@@ -61,6 +61,21 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("composer assignee picker", () => {
|
||||
it.each([false, true])("allows model and effort changes with a fixed conversation agent (mobile: %s)", async (mobile) => {
|
||||
const onAssigneeChange = vi.fn();
|
||||
const onSettingsChange = vi.fn();
|
||||
render(onAssigneeChange, onSettingsChange, false, { allowAssigneeChange: false, mobile,
|
||||
modelOptionsOverride: [{ id: "gpt-6-astra", label: "GPT-6 Astra" }] });
|
||||
expect(container!.querySelector('[aria-label="Select assignee"]')).toBeNull();
|
||||
await click("Select model and effort");
|
||||
expect(document.querySelector('[aria-label="Choose assignee"]')).toBeNull();
|
||||
expect(document.querySelector('input[aria-label="Effort"]')).not.toBeNull();
|
||||
await click("Choose exact model");
|
||||
await click("GPT-6 Astra");
|
||||
expect(onSettingsChange).toHaveBeenLastCalledWith({ model: "gpt-6-astra", effort: "high", fast: false });
|
||||
expect(onAssigneeChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps the trigger stable until the picker closes (mobile: %s)", async (mobile) => {
|
||||
function ControlledPicker() {
|
||||
const [settings, setSettings] = useState({ model: "gpt-6-sol", effort: "high", fast: false });
|
||||
|
||||
@@ -28,6 +28,8 @@ interface Props {
|
||||
settings: ComposerRunSettings | null;
|
||||
onSettingsChange: (settings: ComposerRunSettings | null) => void;
|
||||
onAssigneeChange: (value: string) => void;
|
||||
/** Conversations keep their agent identity; only model/effort is editable here. */
|
||||
allowAssigneeChange?: boolean;
|
||||
disabled?: boolean;
|
||||
mobile?: boolean;
|
||||
triggerRef?: Ref<HTMLButtonElement>;
|
||||
@@ -84,7 +86,7 @@ function AnimatedBody({ children }: { children: ReactNode }) {
|
||||
|
||||
export function ComposerRunSettingsPicker({
|
||||
companyId, assigneeValue, currentAssigneeValue, options, agents, overrides,
|
||||
settings, onSettingsChange, onAssigneeChange, disabled = false, mobile: mobileProp, triggerRef, modelOptionsOverride, renderAssigneeIdentity,
|
||||
settings, onSettingsChange, onAssigneeChange, allowAssigneeChange = true, disabled = false, mobile: mobileProp, triggerRef, modelOptionsOverride, renderAssigneeIdentity,
|
||||
initialOpen = false, initialView = "settings", initialModelSearch = "", initialAssigneeSearch = "",
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(initialOpen);
|
||||
@@ -105,7 +107,9 @@ export function ComposerRunSettingsPicker({
|
||||
const agentId = assigneeValue.startsWith("agent:") ? assigneeValue.slice(6) : "";
|
||||
const agent = agents.get(agentId);
|
||||
const modelSupported = supportsComposerModel(agent);
|
||||
const view = requestedView === "models" && !modelSupported ? "settings" : requestedView;
|
||||
const view = (requestedView === "models" && !modelSupported) || (requestedView === "agents" && !allowAssigneeChange)
|
||||
? "settings"
|
||||
: requestedView;
|
||||
const provider = composerCatalogProvider(agent);
|
||||
const isMuseRunner = agent?.adapterType === "paperclip_runner" && provider === "muse";
|
||||
useEffect(() => {
|
||||
@@ -203,33 +207,34 @@ export function ComposerRunSettingsPicker({
|
||||
};
|
||||
const closeButton = mobile ? <DialogClose asChild><button type="button" aria-label="Close picker" className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent"><X className="size-4" /></button></DialogClose> : null;
|
||||
const Trigger = mobile ? DialogTrigger : PopoverTrigger;
|
||||
const modelTrigger = <button ref={allowAssigneeChange ? undefined : triggerRef} type="button" disabled={disabled} aria-label="Select model and effort" aria-haspopup="dialog" aria-expanded={open && view !== "agents"} onClick={() => { setView("settings"); if (allowAssigneeChange) setOpen(true); }}
|
||||
className="flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full px-2.5 text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
||||
<span data-testid="task-chat-composer-model-label" className="min-w-0 truncate">{open ? "Select model" : modelName || "Default"}</span>
|
||||
{!open && effort ? <span className="hidden shrink-0 sm:inline">{effortLabel}</span> : null}
|
||||
<ChevronDown className="size-3 shrink-0" aria-hidden />
|
||||
</button>;
|
||||
const AssigneeHeader = allowAssigneeChange ? "button" : "div";
|
||||
const triggers = <div data-testid="task-chat-composer-selection" className="flex min-w-0 max-w-full items-center rounded-full bg-muted text-xs font-medium">
|
||||
<Trigger asChild><button ref={triggerRef} type="button" disabled={disabled} aria-label="Select assignee" data-testid="task-chat-composer-assignee"
|
||||
{allowAssigneeChange ? <Trigger asChild><button ref={triggerRef} type="button" disabled={disabled} aria-label="Select assignee" data-testid="task-chat-composer-assignee"
|
||||
onClick={() => { setView("agents"); setHighlightedAssignee(0); }}
|
||||
className="flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full px-2.5 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
||||
{renderAssigneeIdentity?.(assigneeValue, agent?.name ?? "Unassigned", "trigger")}
|
||||
<span data-testid="task-chat-composer-assignee-label" className="min-w-0 truncate">{assigneeOptions.find((item) => item.id === assigneeValue)?.label ?? "Unassigned"}</span>
|
||||
{!modelSupported ? <ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden /> : null}
|
||||
</button></Trigger>
|
||||
</button></Trigger> : null}
|
||||
{modelSupported ? <>
|
||||
<span className="text-muted-foreground" aria-hidden>·</span>
|
||||
<button type="button" disabled={disabled} aria-label="Select model and effort" aria-haspopup="dialog" aria-expanded={open && view !== "agents"} onClick={() => { setView("settings"); setOpen(true); }}
|
||||
className="flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full px-2.5 text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
||||
<span data-testid="task-chat-composer-model-label" className="min-w-0 truncate">{open ? "Select model" : modelName || "Default"}</span>
|
||||
{!open && effort ? <span className="hidden shrink-0 sm:inline">{effortLabel}</span> : null}
|
||||
<ChevronDown className="size-3 shrink-0" aria-hidden />
|
||||
</button>
|
||||
{allowAssigneeChange ? <><span className="text-muted-foreground" aria-hidden>·</span>{modelTrigger}</> : <Trigger asChild>{modelTrigger}</Trigger>}
|
||||
</> : null}
|
||||
</div>;
|
||||
|
||||
const body = view === "settings" ? <div className="min-h-0 overflow-y-auto overscroll-contain touch-pan-y p-3" data-testid="composer-run-settings-view">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" aria-label="Choose assignee" onClick={() => setView("agents")}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<AssigneeHeader type={allowAssigneeChange ? "button" : undefined} aria-label={allowAssigneeChange ? "Choose assignee" : undefined} onClick={allowAssigneeChange ? () => setView("agents") : undefined}
|
||||
className={cn("flex min-w-0 flex-1 items-center gap-2 rounded-md px-1 py-1 text-left", allowAssigneeChange && "hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring")}>
|
||||
{renderAssigneeIdentity?.(assigneeValue, agent?.name ?? "Unassigned", "option") ?? <span className="grid size-6 shrink-0 place-items-center rounded-md bg-secondary text-xs font-semibold text-secondary-foreground">{agent?.name.slice(0, 1) ?? "?"}</span>}
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{agent?.name ?? options.find((item) => item.id === assigneeValue)?.label ?? "Unassigned"}</span><span className="block truncate text-xs text-muted-foreground">{harnessLabel(agent)}</span></span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
{allowAssigneeChange ? <ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden /> : null}
|
||||
</AssigneeHeader>
|
||||
{!choices.length && modelSupported ? <button type="button" aria-label="Reset to agent default" title="Reset to agent default" disabled={!changed} onClick={reset} className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent disabled:opacity-40"><RotateCcw className="size-4" /></button> : null}
|
||||
{closeButton}
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { PaperclipQuestionResponse, PaperclipQuestionSet } from "@paperclip
|
||||
import { QuestionForm } from "./QuestionForm";
|
||||
|
||||
vi.mock("./TaskChatRichInput", () => ({
|
||||
TaskChatRichInput: ({ value, onChange }: { value: string; onChange(value: string): void }) =>
|
||||
<textarea aria-label="Answer" value={value} onChange={event => onChange(event.target.value)} />,
|
||||
TaskChatRichInput: ({ value, onChange, onSubmit }: { value: string; onChange(value: string): void; onSubmit(): void }) =>
|
||||
<textarea aria-label="Answer" value={value} onChange={event => onChange(event.target.value)} onKeyDown={event => { if (event.key === "Enter" && event.metaKey) onSubmit(); }} />,
|
||||
}));
|
||||
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <div>{children}</div> }));
|
||||
|
||||
@@ -49,7 +49,9 @@ describe("QuestionForm initial text", () => {
|
||||
it("retains an explicitly cleared draft and required validation across reload", async () => {
|
||||
await render(); await edit(""); await remount();
|
||||
expect(input().value).toBe("");
|
||||
expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.disabled).toBe(true);
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.click());
|
||||
expect(container.textContent).toContain("This question is required.");
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("isolates pending requests with the same field ids and restores each edited draft when switching back", async () => {
|
||||
@@ -86,7 +88,34 @@ describe("QuestionForm initial text", () => {
|
||||
await act(async () => root.unmount()); localStorage.clear(); root = createRoot(container);
|
||||
await render({ ...questionSet, questions: [{ ...questionSet.questions[0]!, initialText: "bad", textValidation: { minLength: 5 } }] });
|
||||
expect(input().value).toBe("bad");
|
||||
expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.disabled).toBe(true);
|
||||
expect(container.textContent).not.toContain("Enter at least 5 characters.");
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.click());
|
||||
expect(container.textContent).toContain("Enter at least 5 characters.");
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only validates a short answer after Next or keyboard submit, and clears it when corrected", async () => {
|
||||
await render({ schema: "paperclip.question_set.v1", questions: [
|
||||
{ id: "audience", prompt: "Who is it for?", required: true, answerMode: "text", textValidation: { minLength: 2 } },
|
||||
{ id: "outcome", prompt: "What outcome?", required: true, answerMode: "text", textValidation: { minLength: 2 } },
|
||||
] });
|
||||
await edit("m");
|
||||
expect(container.textContent).not.toContain("Enter at least 2 characters.");
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Next")!.click());
|
||||
expect(container.textContent).toContain("Enter at least 2 characters.");
|
||||
expect(container.textContent).toContain("Who is it for?");
|
||||
await edit("millennials");
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Next")!.click());
|
||||
await edit("x");
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
await act(async () => input().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })));
|
||||
expect(container.textContent).toContain("Enter at least 2 characters.");
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
await edit("10 concepts");
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.click());
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith({ schema: "paperclip.question_response.v1", answers: {
|
||||
audience: { text: "millennials" }, outcome: { text: "10 concepts" },
|
||||
} });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
PaperclipQuestionSet,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import type { MentionOption } from "@/components/MarkdownEditor";
|
||||
import { DecisionCardFooter } from "@/components/DecisionCardFooter";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -40,8 +41,11 @@ type Answer = PaperclipQuestionResponse["answers"][string];
|
||||
*/
|
||||
type FormError = { message: string; fromMissingAnswer?: boolean };
|
||||
|
||||
|
||||
export interface QuestionFormProps {
|
||||
footerSlot?: ReactNode;
|
||||
id: string;
|
||||
presentation?: "default" | "decision";
|
||||
questionSet: PaperclipQuestionSet;
|
||||
initialResponse?: PaperclipQuestionResponse | null;
|
||||
implicitCustomAnswer?: boolean;
|
||||
@@ -237,7 +241,9 @@ export function QuestionForm(props: QuestionFormProps) {
|
||||
}
|
||||
|
||||
function QuestionFormForRequest({
|
||||
footerSlot,
|
||||
id,
|
||||
presentation = "default",
|
||||
questionSet,
|
||||
initialResponse,
|
||||
implicitCustomAnswer = false,
|
||||
@@ -289,6 +295,7 @@ function QuestionFormForRequest({
|
||||
const [working, setWorking] = useState<"submit" | "cancel" | null>(null);
|
||||
const [inputUploading, setInputUploading] = useState(false);
|
||||
const [error, setError] = useState<FormError | null>(null);
|
||||
const [attemptedQuestions, setAttemptedQuestions] = useState<Record<string, boolean>>({});
|
||||
const promptRef = useRef<HTMLParagraphElement>(null);
|
||||
const previousPage = useRef(page);
|
||||
|
||||
@@ -322,9 +329,6 @@ function QuestionFormForRequest({
|
||||
),
|
||||
[answers, questionSet.questions],
|
||||
);
|
||||
const allValid = Object.values(validationErrors).every(
|
||||
(value) => value == null,
|
||||
);
|
||||
if (!question)
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -348,6 +352,9 @@ function QuestionFormForRequest({
|
||||
|
||||
function updateAnswer(next: Answer) {
|
||||
setAnswers((current) => ({ ...current, [question.id]: next }));
|
||||
if (answerError(question, next) == null) {
|
||||
setError((current) => current?.fromMissingAnswer ? null : current);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOption(optionId: string) {
|
||||
@@ -395,6 +402,7 @@ function QuestionFormForRequest({
|
||||
answerError(candidate, responseAnswers[candidate.id]) != null,
|
||||
);
|
||||
if (invalidIndex >= 0) {
|
||||
setAttemptedQuestions((current) => ({ ...current, [questionSet.questions[invalidIndex]!.id]: true }));
|
||||
// A required answer is missing: the pagination arrows browse without
|
||||
// validating, and a restored draft can land past it. Go back to that
|
||||
// question and say so rather than dropping the send.
|
||||
@@ -497,6 +505,11 @@ function QuestionFormForRequest({
|
||||
) : null;
|
||||
|
||||
function progressOrSubmit() {
|
||||
if (busy) return;
|
||||
if (currentError) {
|
||||
setAttemptedQuestions((current) => ({ ...current, [question.id]: true }));
|
||||
return;
|
||||
}
|
||||
if (!isLastPage) {
|
||||
setPage((current) =>
|
||||
Math.min(current + 1, questionSet.questions.length - 1),
|
||||
@@ -539,7 +552,7 @@ function QuestionFormForRequest({
|
||||
<MarkdownBody mediaMode="reference" linkIssueReferences={false}>{questionSet.description}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
{question.answerMode === "text" ? (
|
||||
{question.answerMode === "text" && presentation !== "decision" ? (
|
||||
<div className="mb-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{question.answerMode === "text" ? <span>Write an answer</span> : null}
|
||||
</div>
|
||||
@@ -556,7 +569,7 @@ function QuestionFormForRequest({
|
||||
)
|
||||
) : null}
|
||||
<div>
|
||||
{question.header ? (
|
||||
{question.header && presentation !== "decision" ? (
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{question.header}
|
||||
</p>
|
||||
@@ -565,7 +578,8 @@ function QuestionFormForRequest({
|
||||
ref={promptRef}
|
||||
tabIndex={-1}
|
||||
id={`${id}-${question.id}-prompt`}
|
||||
className="text-sm font-medium leading-5 text-foreground"
|
||||
data-decision-prompt={presentation === "decision" ? true : undefined}
|
||||
className={presentation === "decision" ? "text-lg font-medium leading-relaxed text-foreground" : "text-sm font-medium leading-5 text-foreground"}
|
||||
>
|
||||
{question.prompt}
|
||||
</p>
|
||||
@@ -578,6 +592,7 @@ function QuestionFormForRequest({
|
||||
{question.answerMode === "text" ? (
|
||||
<div className="mt-3">
|
||||
<TaskChatRichInput
|
||||
questionAnswer
|
||||
ariaLabelledBy={`${id}-${question.id}-prompt`}
|
||||
testId="question-text-answer-composer"
|
||||
value={answer.text ?? ""}
|
||||
@@ -589,7 +604,7 @@ function QuestionFormForRequest({
|
||||
autoFocus
|
||||
onUploadingChange={setInputUploading}
|
||||
onSubmit={() => {
|
||||
if (!inputUploading && currentError == null) progressOrSubmit();
|
||||
progressOrSubmit();
|
||||
}}
|
||||
attachAriaLabel={`Attach image to answer for ${question.prompt}`}
|
||||
/>
|
||||
@@ -645,6 +660,7 @@ function QuestionFormForRequest({
|
||||
/>
|
||||
{isCustomActive ? (
|
||||
<TaskChatRichInput
|
||||
questionAnswer
|
||||
ariaLabelledBy={`${id}-${question.id}-prompt`}
|
||||
testId="question-other-answer-composer"
|
||||
value={answer.customText ?? ""}
|
||||
@@ -660,8 +676,7 @@ function QuestionFormForRequest({
|
||||
mentions={mentions}
|
||||
onUploadingChange={setInputUploading}
|
||||
onSubmit={() => {
|
||||
if (!inputUploading && currentError == null)
|
||||
progressOrSubmit();
|
||||
progressOrSubmit();
|
||||
}}
|
||||
attachAriaLabel={`Attach image to other answer for ${question.prompt}`}
|
||||
/>
|
||||
@@ -670,8 +685,8 @@ function QuestionFormForRequest({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{currentError && answerHasValue(answer) ? (
|
||||
<p className="mt-2 text-xs text-destructive">{currentError}</p>
|
||||
{currentError && attemptedQuestions[question.id] ? (
|
||||
<p role="alert" className="mt-2 text-xs text-destructive">{currentError}</p>
|
||||
) : null}
|
||||
<div aria-live="assertive">
|
||||
{error ? (
|
||||
@@ -680,44 +695,46 @@ function QuestionFormForRequest({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
|
||||
{cancelAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={cancelAction}
|
||||
>
|
||||
{working === "cancel" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}{" "}
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
{!question.required ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={skipQuestion}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || (isLastPage ? !allValid : currentError != null)}
|
||||
onClick={progressOrSubmit}
|
||||
>
|
||||
{working === "submit" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
<DecisionCardFooter leading={footerSlot} className="mt-3">
|
||||
<div className={cn(!footerSlot && "mt-3", "flex flex-wrap items-center justify-end gap-2")}>
|
||||
{cancelAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={cancelAction}
|
||||
>
|
||||
{working === "cancel" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}{" "}
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
{isLastPage ? (questionSet.submitLabel ?? "Submit answers") : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
{!question.required ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={skipQuestion}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={progressOrSubmit}
|
||||
>
|
||||
{working === "submit" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{isLastPage ? (questionSet.submitLabel ?? "Submit answers") : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</DecisionCardFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CoordinatorRelativeTime } from "@/components/coordinator/CoordinatorRelativeTime";
|
||||
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
||||
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
|
||||
import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
|
||||
import { ArtifactRow, ArtifactRowGroup, inlineRowStyle, subjectFromAttachmentRef } from "@/components/artifacts/v1";
|
||||
@@ -146,6 +148,10 @@ function TaskChatBubbleContent({
|
||||
tryAgainNoLiveExecutionPathPending,
|
||||
}: TaskChatBubbleProps) {
|
||||
const streamlined = useStreamlinedTaskChatPresentation();
|
||||
const coordinator = useCoordinatorPresentation();
|
||||
const recordedTimestamp = item.createdAtIso ?? (typeof item.atMs === "number" && Number.isFinite(item.atMs) ? new Date(item.atMs) : null);
|
||||
const timestamp = coordinator && recordedTimestamp && !item.optimistic
|
||||
? <CoordinatorRelativeTime value={recordedTimestamp} /> : item.timestamp;
|
||||
const { enabled: artifactsV1 } = useArtifactsV1Enabled();
|
||||
const openText = useContext(TextAttachmentContext);
|
||||
// Task attachments share the page gallery; standalone images retain the bubble viewer.
|
||||
@@ -388,6 +394,7 @@ function TaskChatBubbleContent({
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{coordinator && recordedTimestamp && attachedTurn ? <span className="px-1 text-(length:--text-micro) text-muted-foreground"><CoordinatorRelativeTime value={recordedTimestamp!} /></span> : null}
|
||||
{item.optimistic ? (
|
||||
<span className="flex items-center gap-1 px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
<span>{item.optimistic === "queued" ? "Queued" : "Sending…"}</span>
|
||||
@@ -408,9 +415,9 @@ function TaskChatBubbleContent({
|
||||
) : actions ? (
|
||||
streamlined ? (
|
||||
<div className="flex w-full items-center justify-between gap-2 px-1">
|
||||
{item.timestamp ? (
|
||||
{timestamp ? (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||||
{item.timestamp}
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
{actions}
|
||||
@@ -418,19 +425,19 @@ function TaskChatBubbleContent({
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
{actions}
|
||||
{item.timestamp ? (
|
||||
{timestamp ? (
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{item.timestamp}
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : item.timestamp || sentFromIMessage ? (
|
||||
) : timestamp || sentFromIMessage ? (
|
||||
// Timestamps are always visible (round 9) — no longer hover-revealed.
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{sentFromIMessage ? "Sent from iMessage" : null}
|
||||
{sentFromIMessage && item.timestamp ? " · " : null}
|
||||
{item.timestamp}
|
||||
{sentFromIMessage && timestamp ? " · " : null}
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
{artifactsV1 && imageRefs.length + attachmentRefs.length > 0 ? (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { itemVerdictHref } from "@/lib/item-verdict-links";
|
||||
import { Link } from "@/lib/router";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -60,6 +62,11 @@ import {
|
||||
} from "@/lib/issue-thread-interactions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuestionForm } from "./QuestionForm";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { CoordinatorTaskCommitment } from "@/components/coordinator/CoordinatorTaskCommitment";
|
||||
import { CoordinatorInteractionReceipt } from "@/components/coordinator/CoordinatorInteractionReceipt";
|
||||
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
||||
import { decisionReceiptHref } from "@/lib/decision-receipt-navigation";
|
||||
import {
|
||||
TaskChatComposerTakeoverControls,
|
||||
TaskChatComposerTakeoverHeader,
|
||||
@@ -279,9 +286,9 @@ function targetLabel(
|
||||
// detection (e.g. `github:org/repo:pull:16`), not display copy. Only show a
|
||||
// custom target when the agent supplied a human label.
|
||||
if (target.type === "custom" && !target.label) return null;
|
||||
const label = target.label ?? target.key;
|
||||
const label = target.label ?? (target.type === "issue_attachment" ? "Attachment" : target.key);
|
||||
const revision =
|
||||
target.revisionNumber == null ? null : `v${target.revisionNumber}`;
|
||||
target.type === "issue_attachment" || target.revisionNumber == null ? null : `v${target.revisionNumber}`;
|
||||
return [label, revision].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
@@ -296,13 +303,15 @@ function CompactTarget({
|
||||
const target = interaction.payload.target;
|
||||
const label = targetLabel(interaction);
|
||||
if (!target || !label) return null;
|
||||
const href = target.href
|
||||
? normalizeRequestConfirmationTargetHref(target.href)
|
||||
: null;
|
||||
const href = target.type === "issue_attachment"
|
||||
? `/api/attachments/${encodeURIComponent(target.attachmentId)}/content`
|
||||
: target.href ? normalizeRequestConfirmationTargetHref(target.href) : null;
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={target.type === "issue_attachment" ? "_blank" : undefined}
|
||||
rel={target.type === "issue_attachment" ? "noreferrer" : undefined}
|
||||
className="inline-flex rounded-sm border border-border px-2 py-0.5 font-mono text-xs text-foreground hover:bg-muted/60"
|
||||
>
|
||||
{label}
|
||||
@@ -323,6 +332,7 @@ function PlanReviewPreview({
|
||||
interaction: RequestConfirmationInteraction;
|
||||
planDocument?: IssueDocument | null;
|
||||
}) {
|
||||
const coordinator = useCoordinatorPresentation();
|
||||
const target = interaction.payload.target;
|
||||
if (target?.type !== "issue_document" || target.key !== "plan") return null;
|
||||
|
||||
@@ -344,7 +354,7 @@ function PlanReviewPreview({
|
||||
revision: targetRevision,
|
||||
fallbackTitle: target.label,
|
||||
}}
|
||||
href="#document-plan"
|
||||
href={coordinator ? decisionReceiptHref({ issueHref: `/issues/${interaction.issueId}`, interactionId: interaction.id, target }) : "#document-plan"}
|
||||
testId="plan-review-preview"
|
||||
ariaLabel={`Open ${target.label ?? (targetRevision == null ? "plan" : `plan revision ${targetRevision}`)}`}
|
||||
/>
|
||||
@@ -1354,12 +1364,14 @@ function SuggestedTasksCard({
|
||||
onRejectInteraction,
|
||||
errorMessage,
|
||||
draftKey,
|
||||
coordinator = false,
|
||||
}: {
|
||||
interaction: SuggestTasksInteraction;
|
||||
onAcceptInteraction?: SharedInteractionProps["onAcceptInteraction"];
|
||||
onRejectInteraction?: SharedInteractionProps["onRejectInteraction"];
|
||||
errorMessage: (error: unknown) => string;
|
||||
draftKey?: string;
|
||||
coordinator?: boolean;
|
||||
}) {
|
||||
const roots = useMemo(
|
||||
() => buildSuggestedTaskTree(interaction.payload.tasks),
|
||||
@@ -1427,13 +1439,13 @@ function SuggestedTasksCard({
|
||||
});
|
||||
}
|
||||
|
||||
async function resolve(action: "accept" | "reject") {
|
||||
async function resolve(action: "accept" | "reject", rejectionReason = reason) {
|
||||
setWorking(action);
|
||||
setActionError(null);
|
||||
try {
|
||||
if (action === "accept")
|
||||
await onAcceptInteraction?.(interaction, [...selected]);
|
||||
else await onRejectInteraction?.(interaction, reason.trim() || undefined);
|
||||
else await onRejectInteraction?.(interaction, rejectionReason.trim() || undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error));
|
||||
@@ -1444,9 +1456,7 @@ function SuggestedTasksCard({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm leading-5 text-foreground">
|
||||
Select the tasks to create.
|
||||
</p>
|
||||
{(!coordinator || interaction.payload.tasks.length > 1) ? <p className="text-sm leading-5 text-foreground">Select the tasks to create.</p> : null}
|
||||
{interaction.payload.tasks.length > 10 ? (
|
||||
<label className="relative mt-3 block">
|
||||
<Search
|
||||
@@ -1481,7 +1491,7 @@ function SuggestedTasksCard({
|
||||
htmlFor={`${interaction.id}-reject-reason`}
|
||||
className="text-xs font-medium text-foreground"
|
||||
>
|
||||
Why should these tasks change? (optional)
|
||||
{coordinator ? "What should change?" : "Why should these tasks change? (optional)"}
|
||||
</label>
|
||||
<Textarea
|
||||
id={`${interaction.id}-reject-reason`}
|
||||
@@ -1494,8 +1504,11 @@ function SuggestedTasksCard({
|
||||
</div>
|
||||
) : null}
|
||||
<InteractionActionError message={actionError} />
|
||||
{coordinator && interaction.payload.tasks.length > 1 ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">{selected.size} of {interaction.payload.tasks.length} selected</p>
|
||||
) : null}
|
||||
<ActionRow
|
||||
hint={`${selected.size} of ${interaction.payload.tasks.length} selected`}
|
||||
hint={coordinator ? null : `${selected.size} of ${interaction.payload.tasks.length} selected`}
|
||||
>
|
||||
{rejecting ? (
|
||||
<>
|
||||
@@ -1506,23 +1519,25 @@ function SuggestedTasksCard({
|
||||
disabled={working !== null}
|
||||
onClick={() => setRejecting(false)}
|
||||
>
|
||||
Back
|
||||
{coordinator ? "Cancel" : "Back"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={working !== null || !onRejectInteraction}
|
||||
disabled={working !== null || !onRejectInteraction || (coordinator && !reason.trim())}
|
||||
onClick={() => void resolve("reject")}
|
||||
>
|
||||
{working === "reject" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Send back
|
||||
{coordinator ? "Request revision" : "Send back"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{coordinator ? <Button type="button" size="sm" variant="ghost" disabled={working !== null || !onRejectInteraction}
|
||||
onClick={() => { setReason(""); void resolve("reject", ""); }}>Reject</Button> : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -1530,7 +1545,7 @@ function SuggestedTasksCard({
|
||||
disabled={working !== null || !onRejectInteraction}
|
||||
onClick={() => setRejecting(true)}
|
||||
>
|
||||
Revise
|
||||
{coordinator ? "Revise…" : "Revise"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1543,7 +1558,7 @@ function SuggestedTasksCard({
|
||||
{working === "accept" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Create selected
|
||||
{coordinator ? "Create" : "Create selected"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -1630,9 +1645,7 @@ function ItemVerdictsCard({
|
||||
);
|
||||
const resolved = resolvedById.get(item.id);
|
||||
const draft = drafts.get(item.id);
|
||||
const itemHref = item.href
|
||||
? normalizeRequestConfirmationTargetHref(item.href)
|
||||
: null;
|
||||
const itemHref = itemVerdictHref(item, interaction);
|
||||
const rejectionNeedsConfirmation = Boolean(
|
||||
!resolved &&
|
||||
draft &&
|
||||
@@ -1763,9 +1776,13 @@ function ItemVerdictsCard({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{itemHref ? (
|
||||
{itemHref && !itemHref.startsWith("/api/") && (itemHref.startsWith("/") || itemHref.startsWith("#")) ? (
|
||||
<Link to={itemHref} disableIssueQuicklook className="text-xs font-medium text-primary hover:underline">Open</Link>
|
||||
) : itemHref ? (
|
||||
<a
|
||||
href={itemHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Open
|
||||
@@ -1894,6 +1911,7 @@ export function TaskChatCompactInteractionCard({
|
||||
draftKey,
|
||||
mentions,
|
||||
}: TaskChatCompactInteractionCardProps) {
|
||||
const { enabled: coordinatorEnabled } = useCoordinatorWorkspaceEnabled();
|
||||
const creatorLabel = interaction.createdByAgentId
|
||||
? agentMap?.get(interaction.createdByAgentId)?.name
|
||||
: interaction.createdByUserId
|
||||
@@ -1927,6 +1945,24 @@ export function TaskChatCompactInteractionCard({
|
||||
interaction.payload.target?.type === "issue_document" &&
|
||||
interaction.payload.target.key === "plan";
|
||||
|
||||
if (coordinatorEnabled && interaction.kind === "suggest_tasks") {
|
||||
return <CoordinatorTaskCommitment interaction={interaction}>
|
||||
{interaction.status === "pending" ? <div className="p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Suggested Task</h3>
|
||||
{audienceLabel ? <p className="text-xs text-muted-foreground">{audienceLabel}</p> : null}
|
||||
<SuggestedTasksCard interaction={interaction} onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction} errorMessage={errorMessage} draftKey={draftKey} coordinator />
|
||||
</div> : <div className="p-4"><ReceiptDisclosure interaction={interaction} externalReferences={externalReferences} compact /></div>}
|
||||
</CoordinatorTaskCommitment>;
|
||||
}
|
||||
|
||||
if (coordinatorEnabled && presentation === "timeline" && ["accepted", "answered", "rejected"].includes(interaction.status)
|
||||
&& interaction.kind !== "connection_intent"
|
||||
&& interaction.kind !== "request_item_verdicts"
|
||||
&& !(interaction.kind === "request_confirmation" && (interaction.payload.toolAction || interaction.payload.secretProposal || interaction.payload.connectionAuthorization))) {
|
||||
return <CoordinatorInteractionReceipt interaction={interaction} />;
|
||||
}
|
||||
|
||||
if (interaction.kind === "connection_intent") {
|
||||
return (
|
||||
<ConnectionIntentInteractionBody
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
import { parseRunnerGoalCommand, TaskChatComposer } from "./TaskChatComposer";
|
||||
import { ComposerAddMenu } from "./ComposerAddMenu";
|
||||
import { QuestionForm } from "./QuestionForm";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { CoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
||||
import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft";
|
||||
import {
|
||||
loadDraftSubmission,
|
||||
@@ -18,7 +22,6 @@ import {
|
||||
saveDraftSubmission,
|
||||
} from "../../lib/composer-draft";
|
||||
import { CommentSubmissionUnknownError } from "../../lib/comment-submit-result";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
import { useAccountBoundMutation } from "../../hooks/useAccountBoundMutation";
|
||||
|
||||
@@ -2144,6 +2147,42 @@ describe("TaskChatComposer", () => {
|
||||
expect(onAdd.mock.calls[1][4]).not.toBe(onAdd.mock.calls[0][4]);
|
||||
});
|
||||
|
||||
it("shows model controls only in Coordinator chat and submits the choice without reassignment", async () => {
|
||||
vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
|
||||
const catalog = vi.spyOn(agentsApi, "adapterModels").mockResolvedValue([{ id: "gpt-6-astra", label: "GPT-6 Astra" }]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const agent = { id: "chat-agent", name: "Mira", adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" } } as unknown as Agent;
|
||||
const agents = new Map([[agent.id, agent]]);
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
const paint = (coordinator: boolean) => render(<QueryClientProvider client={client}><CoordinatorPresentation.Provider value={coordinator}>
|
||||
<TaskChatComposer onAdd={onAdd} conversationMode companyId="chat-company" workMode="standard"
|
||||
currentAssigneeValue="agent:chat-agent" suggestedAssigneeValue="agent:someone-else" modelAgents={agents}
|
||||
reassignOptions={[{ id: "agent:chat-agent", label: "Mira" }]} />
|
||||
</CoordinatorPresentation.Provider></QueryClientProvider>);
|
||||
try {
|
||||
paint(false);
|
||||
expect(container.querySelector('[aria-label="Select model and effort"]')).toBeNull();
|
||||
paint(true);
|
||||
await flushAsync();
|
||||
expect(container.querySelector('[aria-label="Select assignee"]')).toBeNull();
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Select model and effort"]')!.click());
|
||||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Choose exact model"]')!.click());
|
||||
const option = [...document.querySelectorAll<HTMLButtonElement>('button[role="option"]')].find(item => item.textContent?.startsWith("GPT-6 Astra"))!;
|
||||
await act(async () => option.click());
|
||||
const range = document.querySelector<HTMLInputElement>('input[aria-label="Effort"]')!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(range, "3");
|
||||
range.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
range.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
typeText("Please outline the launch.");
|
||||
await act(async () => sendButton().click());
|
||||
expect(onAdd).toHaveBeenCalledWith("Please outline the launch.", undefined, undefined, undefined, expect.any(String), { model: "gpt-6-astra", effort: "high", fast: false });
|
||||
} finally {
|
||||
render(<></>); client.clear(); catalog.mockRestore(); vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
describe("paused task takeover", () => {
|
||||
it("allows only standalone /new to resume a paused conversation through the normal composer", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -2595,10 +2634,13 @@ describe("TaskChatComposer", () => {
|
||||
const byLabel = (label: string) =>
|
||||
buttons().find((button) => button.textContent?.trim() === label);
|
||||
|
||||
// Page 1: required, so no Skip; Next waits for an answer. Answering
|
||||
// enables Next but stays put — only Next moves on.
|
||||
// Page 1: required, so no Skip. Next explains a missing answer only
|
||||
// after an attempt; selecting an answer still does not navigate.
|
||||
expect(byLabel("Skip")).toBeUndefined();
|
||||
expect(byLabel("Next")?.disabled).toBe(true);
|
||||
expect(byLabel("Next")?.disabled).toBe(false);
|
||||
flushSync(() => byLabel("Next")?.click());
|
||||
expect(container.textContent).toContain("This question is required.");
|
||||
expect(container.textContent).toContain("Where?");
|
||||
flushSync(() => byLabel("Staging")?.click());
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain("Where?");
|
||||
@@ -2661,7 +2703,8 @@ describe("TaskChatComposer", () => {
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain("1 of 3");
|
||||
expect(container.querySelector('[data-testid="question-other-answer-composer"]')).not.toBeNull();
|
||||
expect(byLabel("Next").disabled).toBe(true);
|
||||
expect(byLabel("Next").disabled).toBe(false);
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
flushSync(() => {
|
||||
if (input === "click") byLabel("SQLite").click();
|
||||
else byLabel("SQLite").dispatchEvent(new KeyboardEvent("keydown", { key: "1", bubbles: true }));
|
||||
|
||||
@@ -75,7 +75,8 @@ import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPaused
|
||||
import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker";
|
||||
import { TaskChatComposerBar } from "./TaskChatComposerBar";
|
||||
import { ComposerAddMenu, ComposerModeChip, ComposerPrivacyChip } from "./ComposerAddMenu";
|
||||
import type { ComposerRunSettings } from "./composer-run-settings";
|
||||
import { supportsComposerModel, type ComposerRunSettings } from "./composer-run-settings";
|
||||
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
||||
import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
|
||||
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
|
||||
@@ -456,6 +457,7 @@ export function TaskChatComposer({
|
||||
const [localPendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
|
||||
const pendingMode = creation ? workMode : localPendingMode;
|
||||
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
|
||||
const coordinator = useCoordinatorPresentation();
|
||||
const [localRunSettings, setLocalRunSettings] = useState<ComposerRunSettings | null>(null);
|
||||
const runSettings = creation ? creation.runSettings : localRunSettings;
|
||||
const setRunSettings = creation ? creation.onRunSettingsChange : setLocalRunSettings;
|
||||
@@ -664,7 +666,8 @@ export function TaskChatComposer({
|
||||
const showAssignee = Boolean(
|
||||
enableReassign && reassignOptions && reassignOptions.length > 0,
|
||||
);
|
||||
const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const assigneeValue = creation || conversationMode ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const showConversationModel = coordinator && conversationMode && supportsComposerModel(modelAgents?.get(assigneeValue.slice(6)));
|
||||
const assigneeLabel =
|
||||
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
|
||||
const assigneeName =
|
||||
@@ -1552,7 +1555,7 @@ export function TaskChatComposer({
|
||||
|
||||
<div className={cn("ml-auto flex min-w-0 max-w-full items-center gap-2", mobile && !queuedEdit && "flex-1 justify-end")}>
|
||||
|
||||
{showAssignee && !queuedEdit && companyId && modelAgents ? (
|
||||
{(showAssignee || showConversationModel) && !queuedEdit && companyId && modelAgents ? (
|
||||
<ComposerRunSettingsPicker
|
||||
companyId={companyId}
|
||||
assigneeValue={assigneeValue}
|
||||
@@ -1563,6 +1566,7 @@ export function TaskChatComposer({
|
||||
settings={runSettings}
|
||||
onSettingsChange={setRunSettings}
|
||||
onAssigneeChange={updatePendingAssignee}
|
||||
allowAssigneeChange={showAssignee && !conversationMode}
|
||||
renderAssigneeIdentity={(value, label, placement) => (
|
||||
<AssigneeIdentityAvatar
|
||||
assigneeValue={value}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TaskChatComposerDockProps {
|
||||
@@ -10,7 +10,23 @@ interface TaskChatComposerDockProps {
|
||||
|
||||
/** A normal flex footer beside the message scroller, on desktop and mobile. */
|
||||
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const dock = dockRef.current;
|
||||
const thread = dock?.closest<HTMLElement>('[data-testid="task-chat-thread"]');
|
||||
if (!dock || !thread || !thread.closest(".coordinator-workspace")) return;
|
||||
const measure = () => thread.style.setProperty("--task-composer-dock-height", `${dock.getBoundingClientRect().height}px`);
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(dock);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
thread.style.removeProperty("--task-composer-dock-height");
|
||||
};
|
||||
}, []);
|
||||
return <div
|
||||
ref={dockRef}
|
||||
data-testid="task-chat-composer-dock"
|
||||
data-conversation-typography=""
|
||||
inert={concealed}
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("@/components/MarkdownEditor", () => ({
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { CoordinatorPresentation } from "../coordinator/CoordinatorPresentation";
|
||||
import { TaskChatDescriptionBubble, type TaskChatIssueBrief } from "./TaskChatDescriptionBubble";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -89,6 +90,18 @@ describe("TaskChatDescriptionBubble (PAP-375)", () => {
|
||||
expect(bubble?.querySelector('[data-testid="task-chat-description-edit"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the task creation time relatively in Coordinator and preserves Classic time formatting", () => {
|
||||
const createdAt = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
act(() => root!.render(<ThemeProvider><CoordinatorPresentation.Provider value>
|
||||
<TaskChatDescriptionBubble brief={makeBrief({ createdAt })} />
|
||||
</CoordinatorPresentation.Provider></ThemeProvider>));
|
||||
expect(container.querySelector("time")?.getAttribute("datetime")).toBe(createdAt);
|
||||
expect(container.querySelector("time")?.textContent).toMatch(/\d+m ago/);
|
||||
render(makeBrief({ createdAt }));
|
||||
expect(container.querySelector("time")).toBeNull();
|
||||
expect(container.textContent).not.toContain("m ago");
|
||||
});
|
||||
|
||||
it("renders an agent-created task as the agent-side bubble with the avatar author header", () => {
|
||||
render(makeBrief({ author: "agent", authorName: "CEO" }));
|
||||
const bubble = container.querySelector('[data-testid="task-chat-description-bubble"]');
|
||||
|
||||
@@ -8,6 +8,8 @@ import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/Ma
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import type { MentionOption } from "@/components/MarkdownEditor";
|
||||
import { formatTaskChatTimestamp } from "./task-chat-adapter";
|
||||
import { useCoordinatorPresentation } from "../coordinator/CoordinatorPresentation";
|
||||
import { CoordinatorRelativeTime } from "../coordinator/CoordinatorRelativeTime";
|
||||
|
||||
/**
|
||||
* Host binding for the description-as-first-bubble (PAP-375): the LIVE issue
|
||||
@@ -59,6 +61,7 @@ function initialsForName(name: string) {
|
||||
* an empty bubble.
|
||||
*/
|
||||
export function TaskChatDescriptionBubble({ brief }: TaskChatDescriptionBubbleProps) {
|
||||
const coordinator = useCoordinatorPresentation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const isHuman = brief.author === "human";
|
||||
const hasDescription = brief.description.trim().length > 0;
|
||||
@@ -104,7 +107,9 @@ export function TaskChatDescriptionBubble({ brief }: TaskChatDescriptionBubblePr
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = formatTaskChatTimestamp(brief.createdAt);
|
||||
const timestamp = coordinator && brief.createdAt
|
||||
? <CoordinatorRelativeTime value={brief.createdAt} />
|
||||
: formatTaskChatTimestamp(brief.createdAt);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
pendingRequestItemVerdictsInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
|
||||
vi.mock("@/lib/router", async importOriginal => ({
|
||||
...await importOriginal<typeof import("@/lib/router")>(),
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
import { TaskChatThreadView } from "./TaskChatThreadView";
|
||||
import type { TaskChatInteractionItem } from "./task-chat-model";
|
||||
|
||||
@@ -838,6 +842,31 @@ describe("TaskChatInteractionCard", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it("opens a compact attachment item through the authenticated file endpoint", () => {
|
||||
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
|
||||
items: [{ id: "file", label: "Reviewed file", target: { type: "issue_attachment" as const, attachmentId: "immutable-file", assetId: "asset", sha256: "hash" } }],
|
||||
} };
|
||||
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
|
||||
expect(container.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it.each([undefined, "/stale-display-link"])("opens a compact confirmation's bound attachment when href is %s", href => {
|
||||
const interaction = createRequestConfirmation({ payload: { version: 1, prompt: "Review the attached copy", target: {
|
||||
type: "issue_attachment", attachmentId: "immutable-file", assetId: "asset", sha256: "hash", href,
|
||||
} } });
|
||||
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onAcceptInteraction={() => undefined} /></ThemeProvider></TooltipProvider>));
|
||||
expect(container.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it("opens the bound output revision from a compact review item", () => {
|
||||
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
|
||||
items: [{ id: "copy", label: "Reviewed copy", target: { type: "issue_document" as const, issueId: "owner", key: "copy", revisionId: "reviewed-v1" } }],
|
||||
} };
|
||||
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
|
||||
expect(container.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href"))
|
||||
.toBe(`/issues/owner#document-copy&revision=reviewed-v1&interaction=${interaction.id}`);
|
||||
});
|
||||
|
||||
it("paginates item verdicts instead of expanding the whole review set", async () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions";
|
||||
import { TaskChatCompactInteractionCard } from "./TaskChatCompactInteractionCard";
|
||||
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
|
||||
import type { TaskChatInteractionItem } from "./task-chat-model";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
|
||||
type InteractionCardProps = Omit<
|
||||
ComponentProps<typeof IssueThreadInteractionCard>,
|
||||
@@ -46,7 +47,13 @@ export function TaskChatInteractionCard({
|
||||
draftKey,
|
||||
...cardProps
|
||||
}: TaskChatInteractionCardProps) {
|
||||
const { enabled: coordinatorEnabled } = useCoordinatorWorkspaceEnabled();
|
||||
const interaction = item.interaction;
|
||||
if (coordinatorEnabled && interaction.kind === "suggest_tasks" && !shouldHideInteractionCard(interaction)) {
|
||||
return <div id={`interaction-${interaction.id}`} data-testid="task-chat-interaction" className="w-full">
|
||||
<TaskChatCompactInteractionCard interaction={interaction} presentation={presentation} draftKey={draftKey} {...cardProps} />
|
||||
</div>;
|
||||
}
|
||||
if (presentation === "timeline" && showUnansweredQuestion && !shouldHideInteractionCard(interaction) && interaction.kind === "ask_user_questions" && interaction.status === "pending") {
|
||||
const prompt = interaction.payload.questionSet?.questions[0]?.prompt ?? interaction.payload.questions[0]?.prompt ?? interaction.title ?? "Question";
|
||||
return (
|
||||
@@ -147,12 +154,13 @@ export function TaskChatInteractionCard({
|
||||
interaction.payload.target.key === "plan";
|
||||
const resolvedPlanMatchesDocument =
|
||||
isResolvedPlanReview &&
|
||||
interaction.kind === "request_confirmation" && interaction.payload.target?.type === "issue_document" &&
|
||||
planDocument != null &&
|
||||
(planDocument.latestRevisionId
|
||||
? interaction.payload.target?.revisionId === planDocument.latestRevisionId
|
||||
: interaction.payload.target?.revisionNumber ===
|
||||
planDocument.latestRevisionNumber);
|
||||
if (isResolvedPlanReview) {
|
||||
if (isResolvedPlanReview && !coordinatorEnabled) {
|
||||
if (resolvedPlanMatchesDocument && showPlanPreview && planDocument) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { completeRequestItemVerdictsInteraction } from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
|
||||
|
||||
const preference = vi.hoisted(() => ({ enabled: false }));
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => preference }));
|
||||
|
||||
describe("completed item-review receipts", () => {
|
||||
it.each([false, true])("retains inspectable verdicts and reasons with Coordinator enabled=%s", enabled => {
|
||||
preference.enabled = enabled;
|
||||
const interaction = completeRequestItemVerdictsInteraction;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
try {
|
||||
flushSync(() => root.render(<TooltipProvider><ThemeProvider>
|
||||
<TaskChatInteractionCard presentation="timeline" item={{ id: `interaction:${interaction.id}`, kind: "interaction", interaction }} />
|
||||
</ThemeProvider></TooltipProvider>));
|
||||
const disclosure = host.querySelector<HTMLDetailsElement>('details[data-testid="task-chat-interaction-receipt"]');
|
||||
expect(disclosure).not.toBeNull();
|
||||
expect(disclosure!.open).toBe(false);
|
||||
disclosure!.querySelector("summary")!.click();
|
||||
expect(disclosure!.open).toBe(true);
|
||||
for (const result of interaction.result!.items) {
|
||||
const label = interaction.payload.items.find(item => item.id === result.id)!.label;
|
||||
const row = [...disclosure!.querySelectorAll("li")].find(item => item.textContent?.includes(label));
|
||||
expect(row?.textContent).toContain(result.verdict);
|
||||
if (result.reason) expect(row?.textContent).toContain(result.reason);
|
||||
}
|
||||
expect(host.querySelector('a[href^="#comment-interaction-response:"]')).toBeNull();
|
||||
} finally {
|
||||
flushSync(() => root.unmount());
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import {
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
TaskChatPlanPreviewCard,
|
||||
} from "./TaskChatPlanPreviewCard";
|
||||
import type { TaskChatProviderActivityItem } from "./task-chat-model";
|
||||
import { CoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
||||
|
||||
vi.mock("@/lib/router", () => ({ Link: ({ to, children, disableIssueQuicklook: _quicklook, ...props }: any) => <a href={to} {...props}>{children}</a> }));
|
||||
|
||||
function planDocument(overrides: Partial<IssueDocument> = {}): IssueDocument {
|
||||
return {
|
||||
@@ -107,6 +110,26 @@ describe("TaskChatPlanPreviewCard", () => {
|
||||
expect(container.textContent).not.toContain("Streaming task UX");
|
||||
});
|
||||
|
||||
it("keeps Coordinator plans compact and links the saved revision in its owning task", () => {
|
||||
act(() => root.render(<ThemeProvider><CoordinatorPresentation.Provider value>
|
||||
<TaskChatPlanPreviewCard source={{ kind: "saved", document: planDocument() }} />
|
||||
</CoordinatorPresentation.Provider></ThemeProvider>));
|
||||
const card = container.querySelector("a")!;
|
||||
expect(card.getAttribute("href")).toBe("/issues/issue-1#document-plan&revision=plan-revision-4");
|
||||
expect(card.textContent).toContain("Open Streaming task UX");
|
||||
expect(container.querySelector("ul")).toBeNull();
|
||||
expect(container.textContent).not.toContain("Extract the shared card.");
|
||||
});
|
||||
|
||||
it("does not invent a task link for an unsaved live plan", () => {
|
||||
act(() => root.render(<ThemeProvider><CoordinatorPresentation.Provider value>
|
||||
<TaskChatPlanPreviewCard source={{ kind: "live", activity: livePlan([{ id: "a", label: "Internal step", status: "in_progress" }]) }} />
|
||||
</CoordinatorPresentation.Provider></ThemeProvider>));
|
||||
expect(container.textContent).toContain("Writing Plan");
|
||||
expect(container.textContent).not.toContain("Internal step");
|
||||
expect(container.querySelector("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates a live provider preview in place as steps stream", () => {
|
||||
const render = (steps: TaskChatProviderActivityItem["steps"]) => act(() => root.render(
|
||||
<ThemeProvider>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import { Lightbulb, Maximize2 } from "lucide-react";
|
||||
import { FileText, Lightbulb, Maximize2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarDocumentLink } from "@/components/SidebarDocumentLink";
|
||||
import { buildDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
||||
import { useTaskChatShimmer } from "./TaskChatShimmer";
|
||||
import type { TaskChatProviderActivityItem } from "./task-chat-model";
|
||||
|
||||
@@ -83,6 +86,7 @@ export function TaskChatPlanPreviewCard({
|
||||
testId = "task-chat-plan-preview",
|
||||
className,
|
||||
}: TaskChatPlanPreviewCardProps) {
|
||||
const coordinator = useCoordinatorPresentation();
|
||||
const shimmer = useTaskChatShimmer(source.kind === "live" ? source.activity.id : "saved-plan");
|
||||
const revision = planRevision(source);
|
||||
const content = source.kind === "saved" && source.document
|
||||
@@ -91,6 +95,24 @@ export function TaskChatPlanPreviewCard({
|
||||
? { title: source.fallbackTitle?.trim() || "Plan", preview: [] as string[] }
|
||||
: livePlanContent(source.activity);
|
||||
const live = source.kind === "live";
|
||||
if (coordinator) {
|
||||
const document = source.kind === "saved" ? source.document : null;
|
||||
const targetHref = href === "#document-plan" && document
|
||||
? `/issues/${document.issueId}${buildDocumentAnnotationHash({ documentKey: document.key,
|
||||
revisionId: document.latestRevisionId ?? undefined, threadId: null, commentId: null })}`
|
||||
: href;
|
||||
const compactClassName = cn("inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-muted/25 px-3 py-2.5 text-sm text-muted-foreground",
|
||||
targetHref && "transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className);
|
||||
const compactBody = <><FileText className="size-4 shrink-0" aria-hidden /><span className="shrink-0">{live ? "Writing" : "Open"}</span>{" "}
|
||||
<span className={cn("min-w-0 truncate text-foreground", targetHref && "underline underline-offset-4", live && shimmer && "shimmer-text shimmer-text-muted")}>{content.title}</span>
|
||||
{targetHref ? <span className="hidden shrink-0 sm:inline">{" "}in the sidebar</span> : null}</>;
|
||||
return targetHref ? <SidebarDocumentLink href={targetHref} testId={testId}
|
||||
ariaLabel={ariaLabel ?? `Open ${content.title}${revision == null ? "" : ` revision ${revision}`} in task sidebar`} className={className}>
|
||||
<span className="shrink-0">Open</span>{" "}<span className="min-w-0 truncate text-foreground underline underline-offset-4">{content.title}</span>
|
||||
<span className="hidden shrink-0 sm:inline">{" "}in the sidebar</span>
|
||||
</SidebarDocumentLink>
|
||||
: <section data-testid={testId} aria-label={ariaLabel ?? "Writing plan"} className={compactClassName}>{compactBody}</section>;
|
||||
}
|
||||
const headerDetail = revision == null
|
||||
? null
|
||||
: `rev ${revision}`;
|
||||
|
||||
@@ -949,6 +949,9 @@ describe("TaskChatProtocolCard", () => {
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((button) => button.getAttribute("aria-label") === "Next question");
|
||||
expect(next?.disabled).toBe(false);
|
||||
expect(container.textContent).not.toContain("Enter a custom answer.");
|
||||
const submitNext = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.trim() === "Next");
|
||||
await act(async () => submitNext?.click());
|
||||
expect(container.textContent).toContain("Enter a custom answer.");
|
||||
|
||||
const textarea = container.querySelector("textarea")!;
|
||||
|
||||
@@ -21,6 +21,7 @@ interface TaskChatRichInputProps {
|
||||
testId?: string;
|
||||
attachAriaLabel?: string;
|
||||
showImageAttachControls?: boolean;
|
||||
questionAnswer?: boolean;
|
||||
}
|
||||
|
||||
/** Composer-grade rich text field for takeover answers and revision notes. */
|
||||
@@ -38,6 +39,7 @@ export function TaskChatRichInput({
|
||||
testId = "task-chat-rich-input",
|
||||
attachAriaLabel = "Attach image",
|
||||
showImageAttachControls = false,
|
||||
questionAnswer = false,
|
||||
}: TaskChatRichInputProps) {
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -93,6 +95,7 @@ export function TaskChatRichInput({
|
||||
role="group"
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
data-testid={testId}
|
||||
data-question-answer={questionAnswer || undefined}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
|
||||
@@ -26,6 +26,9 @@ import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||||
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
|
||||
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
|
||||
import { compactAdjacentErrors } from "./adjacent-errors";
|
||||
import { CoordinatorChatTaskHandoff } from "@/components/coordinator/CoordinatorChatTaskHandoffs";
|
||||
import { CoordinatorChatDecisionReceipt } from "@/components/coordinator/CoordinatorChatDecisionReceipts";
|
||||
import { CoordinatorChatPendingQuestion } from "@/components/coordinator/CoordinatorChatPendingQuestions";
|
||||
|
||||
const EMPTY_ATTACHMENTS: IssueAttachment[] = [];
|
||||
|
||||
@@ -101,6 +104,9 @@ function renderItem(
|
||||
onOpenBrowser?: (browserId: string) => void,
|
||||
) {
|
||||
switch (item.kind) {
|
||||
case "pending_question": return <CoordinatorChatPendingQuestion item={item.question} />;
|
||||
case "task_handoff": return <CoordinatorChatTaskHandoff handoff={item.handoff} animateEntry={item.animateEntry} />;
|
||||
case "decision_receipt": return <CoordinatorChatDecisionReceipt receipt={item.receipt} />;
|
||||
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} />;
|
||||
|
||||
@@ -13,3 +13,10 @@ describe("document scroll navigation", () => {
|
||||
expect(taskChatScrollEntry({ key: "entry", hash: "#document-plan", pathname: "/PAP/issues/PAP-1", state })).toEqual({ key: "entry", hash: "#document-plan", pathname: "/PAP/issues/PAP-1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("decision-context navigation", () => {
|
||||
it("opens the decision anchor even when an earlier document preserved a reading position", () => {
|
||||
const location = { key: "decision-entry", hash: "#document-copy&revision=v3&interaction=review-1", pathname: "/PAP/issues/PAP-1", state: { taskDocumentScrollEntry: { key: "old", hash: "#comment-2", pathname: "/PAP/issues/PAP-1" } } };
|
||||
expect(taskChatScrollEntry(location)).toEqual({ key: "decision-entry", hash: location.hash, pathname: location.pathname });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,23 @@
|
||||
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import { type ThreadScrollAnchor, threadScrollAnchorDelta } from "./scroll-anchor";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export const TaskChatScrollNavigation = createContext<{ key: string; restore: boolean; hash: string } | null>(null);
|
||||
export const TaskChatScrollReady = createContext(true);
|
||||
|
||||
/** Decision links name the conversation anchor separately from the artifact. */
|
||||
export function taskChatHashAnchor(hash: string): string | null {
|
||||
const documentTarget = parseDocumentAnnotationHash(hash);
|
||||
if (documentTarget?.interactionId) return `interaction-${documentTarget.interactionId}`;
|
||||
if (documentTarget) return null;
|
||||
try { return hash ? decodeURIComponent(hash.slice(1).split("&")[0]!) : null; } catch { return null; }
|
||||
}
|
||||
|
||||
/** Document navigation opens a pane without starting a new thread reading. */
|
||||
export function taskChatScrollEntry(location: { key: string; hash: string; pathname: string; state: unknown }): { key: string; hash: string; pathname: string } {
|
||||
if (parseDocumentAnnotationHash(location.hash)?.interactionId) {
|
||||
return { key: location.key, hash: location.hash, pathname: location.pathname };
|
||||
}
|
||||
const saved = location.state && typeof location.state === "object" && "taskDocumentScrollEntry" in location.state
|
||||
? location.state.taskDocumentScrollEntry : null;
|
||||
if (saved && typeof saved === "object" && "key" in saved && "hash" in saved && "pathname" in saved && saved.pathname === location.pathname && typeof saved.key === "string" && typeof saved.hash === "string") {
|
||||
@@ -41,9 +53,8 @@ export function useTaskChatScrollNavigation() {
|
||||
return saved.top;
|
||||
}
|
||||
if (navigation.hash) {
|
||||
let id: string;
|
||||
try { id = decodeURIComponent(navigation.hash.slice(1)); } catch { return null; }
|
||||
const target = document.getElementById(id);
|
||||
const id = taskChatHashAnchor(navigation.hash);
|
||||
const target = id ? document.getElementById(id) : null;
|
||||
if (target && root.contains(target)) return scrollTop + target.getBoundingClientRect().top - viewportTop;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -172,7 +172,9 @@ describe("commentsToTaskChatItems", () => {
|
||||
expect(agent.presentation).toBeUndefined();
|
||||
expect(agent.metadata).toBeUndefined();
|
||||
expect(agent.runAgentId).toBeUndefined();
|
||||
expect(agent.createdAtIso).toBeUndefined();
|
||||
// All durable messages retain their source time for the Coordinator
|
||||
// relative footer; the classic preformatted timestamp remains unchanged.
|
||||
expect(agent.createdAtIso).toBe("2026-08-07T09:01:00.000Z");
|
||||
});
|
||||
|
||||
it("keeps the regular comment time for a causally repositioned steered follow-up", () => {
|
||||
|
||||
@@ -150,7 +150,7 @@ export function commentsToTaskChatItems(
|
||||
presentation: kind === "system" ? comment.presentation ?? null : undefined,
|
||||
metadata: kind === "system" ? comment.metadata ?? null : undefined,
|
||||
runAgentId: kind === "system" ? comment.runAgentId ?? null : undefined,
|
||||
createdAtIso: kind === "system" ? createdAtIso : undefined,
|
||||
createdAtIso,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
@@ -20,6 +20,9 @@ import type {
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
IssueWorkProduct,
|
||||
DecisionHistoryItem,
|
||||
ConversationTaskHandoff,
|
||||
AttentionItem,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
PaperclipQuestionResponse,
|
||||
@@ -582,6 +585,9 @@ export interface TaskChatBrowserItem {
|
||||
}
|
||||
|
||||
export type TaskChatItem =
|
||||
| { id: string; kind: "pending_question"; question: AttentionItem }
|
||||
| { id: string; kind: "decision_receipt"; receipt: DecisionHistoryItem }
|
||||
| { id: string; kind: "task_handoff"; handoff: ConversationTaskHandoff; animateEntry?: boolean }
|
||||
| TaskChatBrowserItem
|
||||
| TaskChatProjectCreatedItem
|
||||
| TaskChatSkillCreatedItem
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
|
||||
/** Suggestions retain this owner when acceptance morphs them into created tasks. */
|
||||
export function taskCreationSourceRunId(item: TaskChatItem): string | null {
|
||||
if (item.kind === "task_handoff") return item.handoff.sourceRunId ?? null;
|
||||
if (item.kind === "interaction" && item.interaction.kind === "suggest_tasks") {
|
||||
return item.interaction.sourceRunId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A task card closes its source agent turn, including its final reply and every
|
||||
* steering segment. Run ownership is explicit; unavailable history and older
|
||||
* handoffs without provenance keep their existing chronological slots.
|
||||
* Apply before settled turns merge so a card also preserves its run boundary.
|
||||
*/
|
||||
export function orderTaskCreationItems(
|
||||
items: TaskChatItem[],
|
||||
sourceRunByItemId: ReadonlyMap<string, string>,
|
||||
): TaskChatItem[] {
|
||||
const lastItemByRun = new Map<string, string>();
|
||||
for (const item of items) {
|
||||
const runId = sourceRunByItemId.get(item.id);
|
||||
if (runId) lastItemByRun.set(runId, item.id);
|
||||
}
|
||||
const cardsByAnchor = new Map<string, TaskChatItem[]>();
|
||||
const relocated = new Set<string>();
|
||||
for (const item of items) {
|
||||
const runId = taskCreationSourceRunId(item);
|
||||
const anchorId = runId ? lastItemByRun.get(runId) : undefined;
|
||||
if (!anchorId) continue;
|
||||
const cards = cardsByAnchor.get(anchorId) ?? [];
|
||||
cards.push(item);
|
||||
cardsByAnchor.set(anchorId, cards);
|
||||
relocated.add(item.id);
|
||||
}
|
||||
if (relocated.size === 0) return items;
|
||||
return items.flatMap(item => relocated.has(item.id)
|
||||
? []
|
||||
: [item, ...(cardsByAnchor.get(item.id) ?? [])]);
|
||||
}
|
||||
@@ -25,6 +25,9 @@ function enumStrings(value: unknown): string[] {
|
||||
}
|
||||
|
||||
describe("Paperclip task protocol surface registry", () => {
|
||||
it("folds activation into the task panel instead of adding a chat message", () => {
|
||||
expect(TASK_PROTOCOL_EVENT_SURFACE_REGISTRY["runtime.activity.recorded"]).toMatchObject({ disposition: "folded", surface: "task_panel" });
|
||||
});
|
||||
it("classifies every canonical event exactly once", () => {
|
||||
const eventSchema = schema("event.schema.json");
|
||||
const properties = record(eventSchema.properties);
|
||||
|
||||
@@ -107,6 +107,7 @@ export const TASK_PROTOCOL_EVENT_SURFACE_REGISTRY: Readonly<Record<string, TaskP
|
||||
...Object.fromEntries(runnerLifecycleEvents.map((eventType) => [eventType, folded("turn", "Routine lifecycle transitions are summarized by the run turn and composer state.")])),
|
||||
...Object.fromEntries(diagnosticEvents.map((eventType) => [eventType, folded("system_notice", "Actionable failure text is folded into the run or system notice.")])),
|
||||
...Object.fromEntries(itemEvents.map((eventType) => [eventType, folded("conversation_or_tool", "Item payloads normalize into messages, reasoning, tools, diffs, or usage." )])),
|
||||
"runtime.activity.recorded": folded("task_panel", "The activated plugin view appears in the task side panel."),
|
||||
"auth.credentials.updated": debugOnly("run_debug", "Credential updates notify the host and remain in run details."),
|
||||
"sandbox.metric": debugOnly("run_debug", "Sandbox telemetry is available in run details, not the primary task thread."),
|
||||
...Object.fromEntries(mcpLifecycleEvents.map((eventType) => [eventType, debugOnly("run_debug", "MCP application transport lifecycle remains in run details unless it produces an actionable failure.")])),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { TaskDocumentPanel } from "./TaskDocumentPanel";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), revision: vi.fn() }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { getDocument: mocks.get, getDocumentRevision: mocks.revision } }));
|
||||
vi.mock("@/lib/router", () => ({ useLocation: () => ({ hash: "#document-copy&revision=old&interaction=review" }) }));
|
||||
vi.mock("@/components/IssueDocumentAnnotations", () => ({
|
||||
DocumentAnnotationsCountChip: () => <span>Annotations</span>,
|
||||
IssueDocumentAnnotations: ({ children, historicalPreview }: { children: React.ReactNode; historicalPreview: boolean }) => <div data-historical={historicalPreview}>{children}</div>,
|
||||
}));
|
||||
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }));
|
||||
let root: Root | undefined;
|
||||
let container: HTMLDivElement;
|
||||
afterEach(async () => { if (root) await act(async () => root!.unmount()); container?.remove(); root = undefined; vi.clearAllMocks(); });
|
||||
async function render(issueId = "task") {
|
||||
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => { root!.render(<QueryClientProvider client={client}><TaskDocumentPanel issueId={issueId} documentKey="copy" revisionId="old" /></QueryClientProvider>); });
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
||||
}
|
||||
describe("exact reviewed revision panel", () => {
|
||||
it("renders the pinned body read-only even when the current document has advanced", async () => {
|
||||
mocks.get.mockResolvedValue({ id: "doc", key: "copy", title: "New copy", body: "LATEST BODY", latestRevisionId: "new", latestRevisionNumber: 4, updatedAt: new Date() });
|
||||
mocks.revision.mockResolvedValue({ id: "old", key: "copy", title: "Reviewed copy", body: "REVIEWED BODY", format: "markdown", revisionNumber: 2, createdAt: new Date() });
|
||||
await render();
|
||||
expect(container.textContent).toContain("REVIEWED BODY");
|
||||
expect(container.textContent).not.toContain("LATEST BODY");
|
||||
expect(container.querySelector('[data-historical="true"]')).not.toBeNull();
|
||||
expect(mocks.revision).toHaveBeenCalledWith("task", "copy", "old");
|
||||
});
|
||||
it("does not silently substitute latest when the reviewed revision is missing", async () => {
|
||||
mocks.get.mockResolvedValue({ id: "doc", key: "copy", title: "New copy", body: "LATEST BODY", latestRevisionId: "new", latestRevisionNumber: 4, updatedAt: new Date() });
|
||||
mocks.revision.mockRejectedValue(new ApiError("Not found", 404, {}));
|
||||
await render();
|
||||
expect(container.textContent).toContain("Reviewed revision unavailable");
|
||||
expect(container.textContent).not.toContain("LATEST BODY");
|
||||
});
|
||||
it("reads the actual document owner and fails closed when that task is inaccessible", async () => {
|
||||
mocks.get.mockRejectedValue(new ApiError("Forbidden", 403, {}));
|
||||
await render("task-b");
|
||||
expect(mocks.get).toHaveBeenCalledWith("task-b", "copy");
|
||||
expect(mocks.revision).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Couldn't load the document");
|
||||
expect(container.querySelector("article")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,24 +15,30 @@ export function TaskDocumentPanel({
|
||||
issueId,
|
||||
documentKey,
|
||||
initialDocument,
|
||||
revisionId,
|
||||
}: {
|
||||
issueId: string;
|
||||
documentKey: string;
|
||||
initialDocument?: IssueDocument;
|
||||
revisionId?: string;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
|
||||
const query = useQuery<IssueDocument | null>({
|
||||
queryKey: [...queryKeys.issues.documents(issueId), documentKey],
|
||||
queryKey: [...queryKeys.issues.documents(issueId), documentKey, ...(revisionId ? ["revision", revisionId] : [])],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await issuesApi.getDocument(issueId, documentKey);
|
||||
const current = await issuesApi.getDocument(issueId, documentKey);
|
||||
if (!revisionId) return current;
|
||||
const revision = await issuesApi.getDocumentRevision(issueId, documentKey, revisionId);
|
||||
return { ...current, title: revision.title, body: revision.body, format: revision.format,
|
||||
latestRevisionId: revision.id, latestRevisionNumber: revision.revisionNumber, updatedAt: revision.createdAt };
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
initialData: initialDocument,
|
||||
initialData: revisionId ? undefined : initialDocument,
|
||||
});
|
||||
|
||||
const view = useQueryView(query);
|
||||
@@ -58,9 +64,9 @@ export function TaskDocumentPanel({
|
||||
<div className="flex items-start gap-3 py-8 text-sm" role="status">
|
||||
<FileQuestion className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Document no longer available</p>
|
||||
<p className="font-medium">{revisionId ? "Reviewed revision unavailable" : "Document no longer available"}</p>
|
||||
<p className="text-muted-foreground">
|
||||
This tab is preserved so the missing resource is explicit. Close it or choose another document.
|
||||
{revisionId ? "The exact reviewed revision could not be found. The current version has not been substituted." : "Close this tab or choose another document."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,12 +81,12 @@ export function TaskDocumentPanel({
|
||||
<span>{`Revision ${document.latestRevisionNumber ?? 1}`}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{`Updated ${new Date(document.updatedAt).toLocaleString()}`}</span>
|
||||
<DocumentAnnotationsCountChip
|
||||
{!revisionId ? <DocumentAnnotationsCountChip
|
||||
issueId={issueId}
|
||||
docKey={document.key}
|
||||
panelOpen={annotationPanelOpen}
|
||||
onToggle={() => setAnnotationPanelOpen((open) => !open)}
|
||||
/>
|
||||
/> : <span>· Reviewed version</span>}
|
||||
</div>
|
||||
</header>
|
||||
{document.body.trim() ? (
|
||||
@@ -90,7 +96,7 @@ export function TaskDocumentPanel({
|
||||
bodyMarkdown={document.body}
|
||||
draftDirty={false}
|
||||
draftConflicted={false}
|
||||
historicalPreview={false}
|
||||
historicalPreview={!!revisionId}
|
||||
locationHash={location.hash}
|
||||
panelOpen={annotationPanelOpen}
|
||||
onPanelOpenChange={setAnnotationPanelOpen}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
taskPanelArtifactsTab,
|
||||
taskPanelDocumentTab,
|
||||
taskPanelPropertiesTab,
|
||||
readTaskSidePanelState,
|
||||
writeTaskSidePanelState,
|
||||
} from "@/lib/task-side-panel-state";
|
||||
import type { TaskPluginPanel } from "@/hooks/useTaskPluginPanels";
|
||||
@@ -83,6 +84,10 @@ vi.mock("@/components/task-detail/TaskDetailRelationsPanel", () => ({
|
||||
}));
|
||||
|
||||
const agentChat = vi.hoisted(() => ({ enabled: true }));
|
||||
const coordinator = vi.hoisted(() => ({ enabled: false }));
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
|
||||
useCoordinatorWorkspaceEnabled: () => ({ enabled: coordinator.enabled, loaded: true }),
|
||||
}));
|
||||
vi.mock("@/hooks/useAgentChatEnabled", () => ({
|
||||
useAgentChatEnabled: () => ({ enabled: agentChat.enabled, loaded: true }),
|
||||
}));
|
||||
@@ -101,7 +106,7 @@ vi.mock("@/components/WorkspaceFileBrowser", () => ({
|
||||
vi.mock("./TaskAttachmentPanel", () => ({ TaskAttachmentPanel: ({ attachmentId }: { attachmentId: string }) => <div>Text file {attachmentId}</div> }));
|
||||
|
||||
vi.mock("./TaskDocumentPanel", () => ({
|
||||
TaskDocumentPanel: ({ documentKey }: { documentKey: string }) => <div>{`Document ${documentKey}`}</div>,
|
||||
TaskDocumentPanel: ({ documentKey, issueId, revisionId, initialDocument }: { documentKey: string; issueId: string; revisionId?: string; initialDocument?: IssueDocument }) => <div data-document-owner={issueId} data-revision-id={revisionId} data-initial-document={initialDocument?.id}>{`Document ${documentKey}`}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./TaskWorkspaceFilePanel", () => ({
|
||||
@@ -137,12 +142,14 @@ describe("TaskSidePanel", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
coordinator.enabled = false;
|
||||
window.localStorage.clear();
|
||||
browserFixture.data = [];
|
||||
browserFixture.control.mockClear();
|
||||
fixture.documents = [];
|
||||
fixture.plan = null;
|
||||
routeFixture.location.search = "";
|
||||
routeFixture.location.hash = "";
|
||||
window.history.replaceState(null, "", routeFixture.location.pathname);
|
||||
routeFixture.navigate.mockReset();
|
||||
container = document.createElement("div");
|
||||
@@ -264,6 +271,49 @@ describe("TaskSidePanel", () => {
|
||||
expect(container.querySelector("[data-artifact-upload-slot]")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens only the requested Coordinator document despite background plans, subtasks and artifacts", async () => {
|
||||
coordinator.enabled = true;
|
||||
fixture.plan = issueDocument("plan", "Unrequested plan");
|
||||
fixture.documents = [fixture.plan, issueDocument("report", "Reviewed report")];
|
||||
routeFixture.location.hash = "#document-report&revision=reviewed-version&interaction=decision-1";
|
||||
const onArtifactsOpened = vi.fn();
|
||||
await render(panel({
|
||||
documentDeepLink: { requestId: 1, documentKey: "report" },
|
||||
artifactsOpenRequestId: 1, onArtifactsOpened,
|
||||
showSubtasksTab: true, childIssues: [issue({ id: "child-1" })],
|
||||
}));
|
||||
expect([...container.querySelectorAll('[role="tab"]')].map(tab => tab.getAttribute("data-side-panel-tab-target")))
|
||||
.toEqual(["document:report:reviewed-version"]);
|
||||
expect(onArtifactsOpened).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("opens another task's exact plan without requiring an origin plan or reusing origin document data", async () => {
|
||||
coordinator.enabled = true;
|
||||
fixture.plan = null;
|
||||
fixture.documents = [issueDocument("plan", "Origin task document must not leak")];
|
||||
routeFixture.location.hash = "#document-plan&revision=b-v1&interaction=review-on-a&documentIssue=task-b";
|
||||
await render(panel({ documentDeepLink: { requestId: 1, documentKey: "plan" } }));
|
||||
const document = container.querySelector("[data-document-owner]");
|
||||
expect(document?.getAttribute("data-document-owner")).toBe("task-b");
|
||||
expect(document?.getAttribute("data-revision-id")).toBe("b-v1");
|
||||
expect(document?.hasAttribute("data-initial-document")).toBe(false);
|
||||
expect(container.textContent).not.toContain("Origin task document must not leak");
|
||||
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("document:task-b:plan:b-v1");
|
||||
});
|
||||
|
||||
it("preserves explicitly saved Coordinator tabs when a decision link adds its document", async () => {
|
||||
coordinator.enabled = true;
|
||||
writeTaskSidePanelState("user-1", "company-1", "task-1", {
|
||||
state: { tabs: [taskPanelPropertiesTab(), taskPanelArtifactsTab()], activeTabId: "properties" },
|
||||
launcherOpen: false, userInteracted: true, autoPlanHandled: false, updatedAt: Date.now(),
|
||||
});
|
||||
fixture.documents = [issueDocument("report", "Report")];
|
||||
await render(panel({ documentDeepLink: { requestId: 1, documentKey: "report" } }));
|
||||
expect([...container.querySelectorAll('[role="tab"]')].map(tab => tab.getAttribute("data-side-panel-tab-target")))
|
||||
.toEqual(["properties", "artifacts", "document:report"]);
|
||||
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Report");
|
||||
});
|
||||
|
||||
it("opens, deduplicates, switches and closes text attachment tabs", async () => {
|
||||
const onAttachmentOpened = vi.fn();
|
||||
await render(panel({ openAttachment: { id: "file-1", title: "AGENTS.md", requestId: 1 }, onAttachmentOpened }));
|
||||
@@ -728,6 +778,30 @@ describe("TaskSidePanel", () => {
|
||||
expect(container.textContent).toContain("Properties content");
|
||||
});
|
||||
|
||||
it.each([false, true])("restores each workspace before persisting a same-task scope switch (mobile=%s)", async (mobile) => {
|
||||
const classic = { tabs: [taskPanelPropertiesTab()], activeTabId: "properties" };
|
||||
const experiment = { tabs: [taskPanelArtifactsTab()], activeTabId: "artifacts" };
|
||||
for (const [scope, state] of [["user-1", classic], ["coordinator:user-1", experiment]] as const) {
|
||||
writeTaskSidePanelState(scope, "company-1", "task-1", {
|
||||
state, launcherOpen: false, userInteracted: true, autoPlanHandled: true, updatedAt: 1,
|
||||
});
|
||||
}
|
||||
await render(panel({ accountScope: "user-1", mobile }));
|
||||
expect(container.textContent).toContain("Properties content");
|
||||
// A preference refreshed from another window can change scope without
|
||||
// changing the task or remounting the public TaskSidePanel component.
|
||||
coordinator.enabled = true;
|
||||
await render(panel({ accountScope: "coordinator:user-1", mobile }));
|
||||
expect(container.textContent).toContain("Artifacts content");
|
||||
expect(container.textContent).not.toContain("Properties content");
|
||||
expect(readTaskSidePanelState("user-1", "company-1", "task-1", false)?.state).toEqual(classic);
|
||||
expect(readTaskSidePanelState("coordinator:user-1", "company-1", "task-1", false)?.state).toEqual(experiment);
|
||||
coordinator.enabled = false;
|
||||
await render(panel({ accountScope: "user-1", mobile }));
|
||||
expect(container.textContent).toContain("Properties content");
|
||||
expect(readTaskSidePanelState("coordinator:user-1", "company-1", "task-1", false)?.state).toEqual(experiment);
|
||||
});
|
||||
|
||||
it("opens a URL-backed workspace file tab outside file-viewer provider ancestry", async () => {
|
||||
routeFixture.location.search = "?file=ui%2Fsrc%2FApp.tsx&line=7&workspace=project";
|
||||
window.history.replaceState(null, "", `${routeFixture.location.pathname}${routeFixture.location.search}`);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CoordinatorConversationTasksPanel } from "@/components/coordinator/CoordinatorConversationTasksPanel";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
|
||||
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
|
||||
import { useTaskBrowsers } from "@/hooks/useTaskBrowsers";
|
||||
@@ -93,6 +95,7 @@ import {
|
||||
} from "@/lib/task-side-panel-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskDocumentPanel } from "./TaskDocumentPanel";
|
||||
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import { TaskWorkspaceFilePanel } from "./TaskWorkspaceFilePanel";
|
||||
import { TaskSkillPanel } from "./TaskSkillPanel";
|
||||
|
||||
@@ -246,7 +249,14 @@ function useTaskSidePanelFileRouting() {
|
||||
};
|
||||
}
|
||||
|
||||
export function TaskSidePanel({
|
||||
export function TaskSidePanel(props: TaskSidePanelProps) {
|
||||
// Restoration happens once in the stateful host. A preference/account
|
||||
// change must replace that host before its persistence callback can write
|
||||
// the previous scope's tabs under the new scope's storage key.
|
||||
return <TaskSidePanelForScope key={JSON.stringify([props.accountScope, props.issue.companyId, props.issue.id])} {...props} />;
|
||||
}
|
||||
|
||||
function TaskSidePanelForScope({
|
||||
pluginPanels = EMPTY_PLUGIN_PANELS,
|
||||
openedPluginPanelKeys,
|
||||
dismissedPluginPanelKeys,
|
||||
@@ -281,11 +291,13 @@ export function TaskSidePanel({
|
||||
openSkillName,
|
||||
onSkillOpened,
|
||||
}: TaskSidePanelProps) {
|
||||
const location = useLocation();
|
||||
const handleScroll = useScrollbarWhileScrolling();
|
||||
// Agent Chat: agent chats swap the conversation issue's own panels for
|
||||
// agent-scoped ones — the agent's tasks lead, and Artifacts lists the
|
||||
// agent's output. With the flag off a chat keeps the issue's own panels.
|
||||
const { enabled: agentChatEnabled } = useAgentChatEnabled();
|
||||
const { enabled: coordinatorWorkspaceEnabled } = useCoordinatorWorkspaceEnabled();
|
||||
const conversationAgentId = agentChatEnabled ? issue.conversationAgentId ?? null : null;
|
||||
const showRelatedTasks = showSubtasksTab && !conversationAgentId;
|
||||
const viewer = useTaskSidePanelFileRouting();
|
||||
@@ -313,25 +325,27 @@ export function TaskSidePanel({
|
||||
const handledDocumentRequestRef = useRef<number | undefined>(undefined);
|
||||
const initialState = useMemo(() => {
|
||||
const restored = restoredRef.current?.state;
|
||||
let tabs = restored?.tabs ?? (conversationAgentId
|
||||
let tabs = coordinatorWorkspaceEnabled
|
||||
? restoredRef.current?.userInteracted ? restored?.tabs ?? [] : []
|
||||
: restored?.tabs ?? (conversationAgentId
|
||||
? [taskPanelAgentTasksTab()]
|
||||
: issue.conversationAgentId ? [taskPanelArtifactsTab()] : [taskPanelPropertiesTab()]);
|
||||
// A tab saved while Agent Chat was on has nothing to render without it;
|
||||
// a chat left with no tabs falls back to its Agent-Chat-off default.
|
||||
if (!conversationAgentId) {
|
||||
const kept = tabs.filter((tab) => tab.payload.kind !== "agent-tasks");
|
||||
if (kept.length === 0 && tabs.length > 0 && issue.conversationAgentId) tabs = [taskPanelArtifactsTab()];
|
||||
if (!coordinatorWorkspaceEnabled && kept.length === 0 && tabs.length > 0 && issue.conversationAgentId) tabs = [taskPanelArtifactsTab()];
|
||||
else tabs = kept;
|
||||
}
|
||||
// Chats used to open on Artifacts by default; move an untouched default
|
||||
// onto the agent's tasks. An Artifacts tab the user chose to keep stays.
|
||||
if (conversationAgentId && !restoredRef.current?.userInteracted
|
||||
if (!coordinatorWorkspaceEnabled && conversationAgentId && !restoredRef.current?.userInteracted
|
||||
&& tabs.length === 1 && tabs[0]!.id === "artifacts") {
|
||||
tabs = [taskPanelAgentTasksTab()];
|
||||
}
|
||||
if (!initialSubtasksAvailableRef.current) {
|
||||
tabs = tabs.filter((tab) => tab.payload.kind !== "subtasks");
|
||||
} else if (!subtasksDismissedRef.current) {
|
||||
} else if (!coordinatorWorkspaceEnabled && !subtasksDismissedRef.current) {
|
||||
tabs = insertSubtasksAfterProperties(tabs);
|
||||
}
|
||||
const requestedActive = restored?.activeTabId ?? "properties";
|
||||
@@ -377,6 +391,7 @@ export function TaskSidePanel({
|
||||
const newestLiveBrowser = browsersQuery.data?.findLast((browser) => browser.status === "running" || browser.status === "idle");
|
||||
useEffect(() => {
|
||||
if (!openBrowserId) return;
|
||||
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
|
||||
setLauncherOpen(false);
|
||||
controller.openTab(taskPanelBrowserTab(openBrowserId));
|
||||
}, [openBrowserId, controller.openTab]);
|
||||
@@ -397,6 +412,7 @@ export function TaskSidePanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (!openSkillId) return;
|
||||
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
|
||||
setLauncherOpen(false);
|
||||
controller.openTab(taskPanelSkillTab(openSkillId, openSkillName ?? "Skill"));
|
||||
onSkillOpened?.(openSkillId);
|
||||
@@ -408,7 +424,7 @@ export function TaskSidePanel({
|
||||
if (hasSubtasksTab) controller.closeTab("subtasks");
|
||||
return;
|
||||
}
|
||||
if (!hasSubtasksTab && !subtasksDismissedRef.current) {
|
||||
if (!coordinatorWorkspaceEnabled && !hasSubtasksTab && !subtasksDismissedRef.current) {
|
||||
controller.resetTabs({
|
||||
tabs: insertSubtasksAfterProperties(controller.tabs),
|
||||
activeTabId: controller.activeTabId,
|
||||
@@ -421,6 +437,7 @@ export function TaskSidePanel({
|
||||
controller.tabs,
|
||||
hasSubtasksTab,
|
||||
subtasksAvailable,
|
||||
coordinatorWorkspaceEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -444,30 +461,36 @@ export function TaskSidePanel({
|
||||
// Surface a newly materialized plan exactly once until the user takes manual
|
||||
// control of the tab set. Persisted empty/custom states therefore stay put.
|
||||
useEffect(() => {
|
||||
if (!planDocument || autoPlanHandledRef.current || userInteractedRef.current) return;
|
||||
if (coordinatorWorkspaceEnabled || !planDocument || autoPlanHandledRef.current || userInteractedRef.current) return;
|
||||
autoPlanHandledRef.current = true;
|
||||
controller.openTab(taskPanelDocumentTab("plan", documentDisplayTitle(planDocument)));
|
||||
}, [controller.openTab, planDocument]);
|
||||
}, [controller.openTab, planDocument, coordinatorWorkspaceEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentDeepLink || handledDocumentRequestRef.current === documentDeepLink.requestId) return;
|
||||
const parsedTarget = parseDocumentAnnotationHash(location.hash);
|
||||
const target = parsedTarget?.documentKey === documentDeepLink.documentKey ? parsedTarget : null;
|
||||
const documentIssueId = target?.documentIssueId && target.documentIssueId !== issue.id ? target.documentIssueId : undefined;
|
||||
if (
|
||||
documentDeepLink.documentKey === "plan" &&
|
||||
planDocument === null
|
||||
planDocument === null && !target?.revisionId && !documentIssueId
|
||||
) return;
|
||||
const document = documents.find((candidate) => candidate.key === documentDeepLink.documentKey);
|
||||
const document = documentIssueId ? undefined : documents.find((candidate) => candidate.key === documentDeepLink.documentKey);
|
||||
const label = document ? documentDisplayTitle(document) : documentDeepLink.documentKey === "plan" ? "Plan" : documentDeepLink.documentKey;
|
||||
// A refresh must not replay a link after the user selects another tab.
|
||||
handledDocumentRequestRef.current = documentDeepLink.requestId;
|
||||
controller.openTab(taskPanelDocumentTab(documentDeepLink.documentKey, label));
|
||||
}, [controller.openTab, documentDeepLink, documents, planDocument]);
|
||||
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
|
||||
controller.openTab(taskPanelDocumentTab(documentDeepLink.documentKey, label, target?.revisionId, documentIssueId));
|
||||
}, [controller.openTab, documentDeepLink, documents, planDocument, location.hash, issue.id]);
|
||||
|
||||
// Existing URL-backed workspace links remain the external integration API.
|
||||
useEffect(() => {
|
||||
if (!fileTabsEnabled) return;
|
||||
if (viewer.state) {
|
||||
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
|
||||
controller.openTab(taskPanelWorkspaceFileTab(viewer.state));
|
||||
} else if (viewer.browse) {
|
||||
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
|
||||
controller.openTab(taskPanelFilesTab());
|
||||
controller.updateTab("files", {
|
||||
payload: {
|
||||
@@ -496,9 +519,9 @@ export function TaskSidePanel({
|
||||
handledArtifactsRequestRef.current = artifactsOpenRequestId;
|
||||
// Background outputs add a discoverable tab without interrupting the
|
||||
// current document, file, or launcher. Opening the pane is a user action.
|
||||
controller.openTab(taskPanelArtifactsTab(), false);
|
||||
if (!coordinatorWorkspaceEnabled) controller.openTab(taskPanelArtifactsTab(), false);
|
||||
onArtifactsOpened?.(artifactsOpenRequestId);
|
||||
}, [artifactsOpenRequestId, controller.openTab, onArtifactsOpened]);
|
||||
}, [artifactsOpenRequestId, controller.openTab, onArtifactsOpened, coordinatorWorkspaceEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openAttachment) return;
|
||||
@@ -600,7 +623,7 @@ export function TaskSidePanel({
|
||||
|
||||
const documentByKey = useMemo(() => new Map(documents.map((document) => [document.key, document])), [documents]);
|
||||
const visualTabs = useMemo<SidePanelTabItem[]>(() => controller.tabs.map((tab) => {
|
||||
const document = tab.payload.kind === "issue-document" ? documentByKey.get(tab.payload.documentKey) : null;
|
||||
const document = tab.payload.kind === "issue-document" && (!tab.payload.documentIssueId || tab.payload.documentIssueId === issue.id) ? documentByKey.get(tab.payload.documentKey) : null;
|
||||
const browserIndex = browsersQuery.data?.findIndex((browser) => tab.payload.kind === "browser" && (browser.id === tab.payload.browserId || browser.sessionId === tab.payload.browserId)) ?? -1;
|
||||
const browserLabel = browserIndex >= 0 && (browsersQuery.data?.length ?? 0) > 1 ? `Browser ${browserIndex + 1}` : null;
|
||||
return {
|
||||
@@ -612,7 +635,7 @@ export function TaskSidePanel({
|
||||
contentMode: tab.contentMode,
|
||||
icon: tabIcon(tab),
|
||||
};
|
||||
}), [controller.tabs, documentByKey, taskCount, taskLabel, tasksTab, browsersQuery.data]);
|
||||
}), [controller.tabs, documentByKey, taskCount, taskLabel, tasksTab, browsersQuery.data, issue.id]);
|
||||
|
||||
const launcherSections = useMemo<SidePanelLauncherSection[]>(() => {
|
||||
const primary: SidePanelLauncherItem[] = conversationAgentId ? [
|
||||
@@ -807,7 +830,9 @@ export function TaskSidePanel({
|
||||
);
|
||||
} else if (activeTab.payload.kind === "agent-tasks") {
|
||||
content = conversationAgentId ? (
|
||||
<AgentTasksPanel companyId={issue.companyId} agentId={conversationAgentId} excludeIssueId={issue.id} />
|
||||
coordinatorWorkspaceEnabled
|
||||
? <CoordinatorConversationTasksPanel conversationIssueId={issue.id} />
|
||||
: <AgentTasksPanel companyId={issue.companyId} agentId={conversationAgentId} excludeIssueId={issue.id} />
|
||||
) : null;
|
||||
} else if (activeTab.payload.kind === "artifacts" && conversationAgentId) {
|
||||
content = <AgentArtifactsPanel companyId={issue.companyId} agentId={conversationAgentId} />;
|
||||
@@ -818,13 +843,14 @@ export function TaskSidePanel({
|
||||
if (viewer.state || viewer.browse) viewer.close();
|
||||
}}><IssuePropertiesArtifactsTab issue={issue} onOpenDocument={openDocument} uploadState={artifactUploads} /></TextAttachmentContext.Provider>;
|
||||
} else if (activeTab.payload.kind === "issue-document") {
|
||||
content = activeTab.payload.documentKey === "plan" ? (
|
||||
content = activeTab.payload.documentKey === "plan" && !activeTab.payload.revisionId && !activeTab.payload.documentIssueId ? (
|
||||
<IssuePropertiesPlansTab issue={issue} inline={inline} />
|
||||
) : (
|
||||
<TaskDocumentPanel
|
||||
issueId={issue.id}
|
||||
issueId={activeTab.payload.documentIssueId ?? issue.id}
|
||||
documentKey={activeTab.payload.documentKey}
|
||||
initialDocument={documentByKey.get(activeTab.payload.documentKey)}
|
||||
revisionId={activeTab.payload.revisionId}
|
||||
initialDocument={!activeTab.payload.documentIssueId || activeTab.payload.documentIssueId === issue.id ? documentByKey.get(activeTab.payload.documentKey) : undefined}
|
||||
/>
|
||||
);
|
||||
} else if (activeTab.payload.kind === "attachment") {
|
||||
|
||||
@@ -16,6 +16,36 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
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");
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
|
||||
entityType: action.startsWith("decision.") ? "decision" : "issue", entityId: "task-1", action,
|
||||
}, { userId: "user-1", agentId: null });
|
||||
for (const queryKey of [queryKeys.attention("company-1"), ["decisions", "company-1"], ["issues", "decision-receipts"], ["issues", "chat-handoffs"], ["issues", "pending-questions"]]) {
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey });
|
||||
}
|
||||
client.clear();
|
||||
});
|
||||
it("drops cached decision content when its access is revoked", () => {
|
||||
const client = new QueryClient();
|
||||
const receiptKey = ["issues", "decision-receipts", "chat-1", "user-1"];
|
||||
const historyKey = [...queryKeys.attention("company-1"), "history"];
|
||||
const questionKey = ["issues", "pending-questions", "chat-1", "user-1"];
|
||||
const taskRecordKey = queryKeys.decisions.taskRecord("company-1", "task-1", "user-1", "decision-1");
|
||||
client.setQueryData(taskRecordKey, { title: "Private decision" });
|
||||
client.setQueryData(questionKey, [{ title: "Private question" }]);
|
||||
client.setQueryData(receiptKey, { items: [{ title: "Private decision" }] });
|
||||
client.setQueryData(historyKey, { items: [{ title: "Private decision" }] });
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
|
||||
entityType: "issue_access_grant", entityId: "grant-1", action: "issue_access_grant.revoked",
|
||||
}, { userId: "user-1", agentId: null });
|
||||
expect(client.getQueryData(receiptKey)).toBeUndefined();
|
||||
expect(client.getQueryData(historyKey)).toBeUndefined();
|
||||
expect(client.getQueryData(questionKey)).toBeUndefined();
|
||||
expect(client.getQueryData(taskRecordKey)).toBeUndefined();
|
||||
client.clear();
|
||||
});
|
||||
it("refreshes saved project access with permission saves and live agent activity", async () => {
|
||||
const client = new QueryClient();
|
||||
const target = queryKeys.agents.projectAccess("agent-1");
|
||||
|
||||
@@ -1381,6 +1381,30 @@ function invalidateActivityQueries(
|
||||
const actorType = readString(payload.actorType);
|
||||
const actorId = readString(payload.actorId);
|
||||
const details = readRecord(payload.details);
|
||||
const decisionProjectionChanged = entityType === "decision" ||
|
||||
["inbox.dismissed", "inbox.snoozed", "inbox.restored"].includes(action ?? "") ||
|
||||
action?.startsWith("issue.thread_interaction_") ||
|
||||
action === "issue.status_decision_recorded" ||
|
||||
action === "issue.conversation_session_started" ||
|
||||
(entityType === "issue" && ["issue.created", "issue.updated", "issue.deleted"].includes(action ?? ""));
|
||||
const decisionAudienceChanged = action?.startsWith("issue_access_grant.") ||
|
||||
action?.startsWith("resource_membership.") ||
|
||||
action?.startsWith("company_membership.") ||
|
||||
action?.startsWith("membership.") || action?.includes("privacy");
|
||||
if (decisionAudienceChanged) {
|
||||
// Drop old permission-filtered payloads immediately while the API resolves
|
||||
// the current audience. A failed refetch must not retain a revoked receipt.
|
||||
for (const queryKey of [queryKeys.attention(companyId), ["decisions", companyId, "task-record"], ["issues", "decision-receipts"], ["issues", "chat-handoffs"], ["issues", "pending-questions"]]) {
|
||||
void queryClient.resetQueries({ queryKey });
|
||||
}
|
||||
} else if (decisionProjectionChanged) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: ["decisions", companyId] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "decision-receipts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "chat-handoffs"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "pending-questions"] });
|
||||
}
|
||||
const ownActorActivity =
|
||||
(actorType === "user" &&
|
||||
!!currentActor.userId &&
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PanelProvider, usePanel } from "./PanelContext";
|
||||
|
||||
const preference = vi.hoisted(() => ({ enabled: false, userId: "person-a" }));
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => preference }));
|
||||
let panel: ReturnType<typeof usePanel>;
|
||||
function Probe() { panel = usePanel(); return <span>{String(panel.panelVisible)}</span>; }
|
||||
describe("Coordinator panel visibility", () => {
|
||||
let root: Root, container: HTMLDivElement;
|
||||
beforeEach(() => { preference.enabled = false; preference.userId = "person-a"; localStorage.clear(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); });
|
||||
afterEach(() => { flushSync(() => root.unmount()); container.remove(); });
|
||||
function render() { flushSync(() => root.render(<PanelProvider><Probe /></PanelProvider>)); }
|
||||
it("starts closed in Coordinator and restores classic visibility when switched off", () => {
|
||||
localStorage.setItem("paperclip:panel-visible", "true"); render();
|
||||
expect(panel.panelVisible).toBe(true);
|
||||
preference.enabled = true; render(); expect(panel.panelVisible).toBe(false);
|
||||
flushSync(() => panel.setPanelVisible(true)); expect(panel.panelVisible).toBe(true);
|
||||
flushSync(() => panel.setPanelVisible(false));
|
||||
expect(localStorage.getItem("paperclip:panel-visible")).toBe("true");
|
||||
preference.enabled = false; render(); expect(panel.panelVisible).toBe(true);
|
||||
});
|
||||
it("does not carry another person's open context pane across identity changes", () => {
|
||||
preference.enabled = true; render(); flushSync(() => panel.setPanelVisible(true));
|
||||
preference.userId = "person-b"; render(); expect(panel.panelVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { createContext, useCallback, useContext, useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import type { SidePanelContentMode } from "@/components/side-panel";
|
||||
|
||||
const STORAGE_KEY = "paperclip:panel-visible";
|
||||
@@ -44,7 +45,13 @@ function writePreference(visible: boolean) {
|
||||
export function PanelProvider({ children }: { children: ReactNode }) {
|
||||
const [panelContent, setPanelContent] = useState<ReactNode | null>(null);
|
||||
const [panelContentMode, setPanelContentMode] = useState<SidePanelContentMode>("padded");
|
||||
const [panelVisible, setPanelVisibleState] = useState(readPreference);
|
||||
const [classicPanelVisible, setPanelVisibleState] = useState(readPreference);
|
||||
const [coordinatorPanelVisible, setCoordinatorPanelVisible] = useState(false);
|
||||
const { enabled: coordinatorWorkspaceEnabled, userId } = useCoordinatorWorkspaceEnabled();
|
||||
// The experiment has an on-demand panel. Its visibility never overwrites
|
||||
// the classic workspace preference, including when the experiment is off.
|
||||
const panelVisible = coordinatorWorkspaceEnabled ? coordinatorPanelVisible : classicPanelVisible;
|
||||
useLayoutEffect(() => setCoordinatorPanelVisible(false), [coordinatorWorkspaceEnabled, userId]);
|
||||
const [panelMaximizeRequested, setPanelMaximizeRequested] = useState(false);
|
||||
|
||||
const requestPanelMaximize = useCallback(() => {
|
||||
@@ -66,17 +73,21 @@ export function PanelProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const setPanelVisible = useCallback((visible: boolean) => {
|
||||
setPanelVisibleState(visible);
|
||||
writePreference(visible);
|
||||
}, []);
|
||||
if (coordinatorWorkspaceEnabled) setCoordinatorPanelVisible(visible);
|
||||
else { setPanelVisibleState(visible); writePreference(visible); }
|
||||
}, [coordinatorWorkspaceEnabled]);
|
||||
|
||||
const togglePanelVisible = useCallback(() => {
|
||||
if (coordinatorWorkspaceEnabled) {
|
||||
setCoordinatorPanelVisible(previous => !previous);
|
||||
return;
|
||||
}
|
||||
setPanelVisibleState((prev) => {
|
||||
const next = !prev;
|
||||
writePreference(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [coordinatorWorkspaceEnabled]);
|
||||
|
||||
return (
|
||||
<PanelContext.Provider
|
||||
|
||||
@@ -33,7 +33,10 @@ import {
|
||||
type ConnectionSetupFlowProps,
|
||||
} from "./ConnectionSetupFlow";
|
||||
|
||||
import { DecisionCardFooter } from "@/components/DecisionCardFooter";
|
||||
|
||||
export interface ConnectionIntentInteractionBodyProps {
|
||||
footerSlot?: ReactNode;
|
||||
interaction: ConnectionIntentInteraction;
|
||||
currentUserId?: string | null;
|
||||
addresseeLabel: string;
|
||||
@@ -42,6 +45,7 @@ export interface ConnectionIntentInteractionBodyProps {
|
||||
}
|
||||
|
||||
export function ConnectionIntentInteractionBody({
|
||||
footerSlot,
|
||||
interaction,
|
||||
currentUserId,
|
||||
addresseeLabel,
|
||||
@@ -295,6 +299,7 @@ export function ConnectionIntentInteractionBody({
|
||||
{status.body ? <p className="mt-1 text-sm text-muted-foreground">{status.body}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<DecisionCardFooter leading={footerSlot} className="mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -322,6 +327,7 @@ export function ConnectionIntentInteractionBody({
|
||||
</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<DecisionCardFooter leading={footerSlot} className="mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -348,12 +354,14 @@ export function ConnectionIntentInteractionBody({
|
||||
</ul>
|
||||
{setupQuery.isError || completeMutation.isError || declineMutation.isError ? <p role="alert" className="text-sm text-destructive">{(completeMutation.error ?? declineMutation.error ?? setupQuery.error)?.message ?? "Couldn’t update this access request."}</p> : null}
|
||||
{setupQuery.data?.canGrantAccess === false ? <p role="status" className="text-sm text-muted-foreground">Connection manager required.</p> : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DecisionCardFooter leading={footerSlot}>
|
||||
<div className={footerSlot ? "flex flex-wrap items-center justify-end gap-2" : "flex items-center justify-between gap-2"}>
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => declineMutation.mutate()}>Not now</Button>
|
||||
<Button size="sm" disabled={busy || !setupQuery.data?.canGrantAccess} onClick={() => completeMutation.mutate(accessRequest.connectionId)}>
|
||||
{completeMutation.isPending ? "Granting access…" : "Grant access"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DecisionCardFooter>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -503,52 +511,55 @@ export function ConnectionIntentInteractionBody({
|
||||
onComplete={async connectionId => { await completeMutation.mutateAsync(connectionId); }}
|
||||
onDecline={() => declineMutation.mutate()}
|
||||
declining={declineMutation.isPending}
|
||||
/> : <div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
{!isAi && <Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={declineMutation.isPending || completeMutation.isPending || authorizing}
|
||||
onClick={() => declineMutation.mutate()}
|
||||
>
|
||||
Not now
|
||||
</Button>}
|
||||
{isAi ? <Button type="button" disabled={completeMutation.isPending || adoptMutation.isPending || selectAiAccountMutation.isPending} onClick={() => open ? closeSetup() : setOpen(true)}>
|
||||
<Plug className="h-4 w-4" />{open ? "Close setup" : readyForAdoption ? "Continue setup" : needsOwnAiConnection ? `Connect ${aiProviderName}` : "Fix connection"}
|
||||
</Button> : <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button">
|
||||
{authorizing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plug className="h-4 w-4" />
|
||||
)}
|
||||
{authorizing
|
||||
? "Continue setup"
|
||||
: needsRetry
|
||||
? "Try again"
|
||||
: setupQuery.data?.existingConnections.length ? "Connect / Use existing" : "Connect"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-3xl"
|
||||
showCloseButton={false}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
focusTargetRef.current?.focus();
|
||||
}}
|
||||
/> : <DecisionCardFooter leading={footerSlot} className="mt-4">
|
||||
<div className={footerSlot ? "flex flex-wrap items-center justify-end gap-2" : "mt-4 flex flex-wrap justify-end gap-2"}>
|
||||
{!isAi && <Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={declineMutation.isPending || completeMutation.isPending || authorizing}
|
||||
onClick={() => declineMutation.mutate()}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>
|
||||
Connect {interaction.payload.serviceName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Complete connection setup without leaving this task.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{setupContent}
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</div>}
|
||||
Not now
|
||||
</Button>}
|
||||
{isAi ? <Button type="button" disabled={completeMutation.isPending || adoptMutation.isPending || selectAiAccountMutation.isPending} onClick={() => open ? closeSetup() : setOpen(true)}>
|
||||
<Plug className="h-4 w-4" />{open ? "Close setup" : readyForAdoption ? "Continue setup" : needsOwnAiConnection ? `Connect ${aiProviderName}` : "Fix connection"}
|
||||
</Button> : <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button">
|
||||
{authorizing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plug className="h-4 w-4" />
|
||||
)}
|
||||
{authorizing
|
||||
? "Continue setup"
|
||||
: needsRetry
|
||||
? "Try again"
|
||||
: setupQuery.data?.existingConnections.length ? "Connect / Use existing" : "Connect"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-3xl"
|
||||
showCloseButton={false}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
focusTargetRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>
|
||||
Connect {interaction.payload.serviceName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Complete connection setup without leaving this task.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{setupContent}
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</div>
|
||||
</DecisionCardFooter>}
|
||||
{isEmail ? <DecisionCardFooter leading={footerSlot} className="mt-4" /> : null}
|
||||
{isAi && open ? <div className="mt-4 border-t border-border pt-4" data-testid="ai-connection-inline-repair">{inlineContent}</div> : null}
|
||||
|
||||
{(!isEmail && completeMutation.isError) ||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AttentionItem } from "@paperclipai/shared";
|
||||
import { pendingAskUserQuestionsInteraction } from "./issueThreadInteractionFixtures";
|
||||
|
||||
export const coordinatorTaskQuestion = {
|
||||
...pendingAskUserQuestionsInteraction,
|
||||
id: "task-audience-question", issueId: "task-launch", title: "Who should the launch post help?",
|
||||
summary: "The draft needs an audience before its examples and success criteria can be chosen.",
|
||||
payload: { version: 1 as const, title: "Who should the launch post help?", submitLabel: "Confirm audience", questions: [{
|
||||
id: "audience", prompt: "Who is the primary reader?", selectionMode: "single" as const, required: true,
|
||||
options: [{ id: "operators", label: "People setting up agents", description: "Explain how to choose access for their team." },
|
||||
{ id: "teammates", label: "Teammates using agents", description: "Explain what they can do with the connected tools." }],
|
||||
}] },
|
||||
};
|
||||
|
||||
export const coordinatorTaskQuestionAttention: AttentionItem = {
|
||||
id: `issue_thread_interaction:${coordinatorTaskQuestion.id}`, companyId: coordinatorTaskQuestion.companyId,
|
||||
sourceKind: "issue_thread_interaction", subject: { kind: "interaction", id: coordinatorTaskQuestion.id,
|
||||
companyId: coordinatorTaskQuestion.companyId, title: coordinatorTaskQuestion.title, identifier: null, status: "pending",
|
||||
href: `/issues/PAPER-102#interaction-${coordinatorTaskQuestion.id}` },
|
||||
relatedIssue: { kind: "issue", id: coordinatorTaskQuestion.issueId, companyId: coordinatorTaskQuestion.companyId,
|
||||
title: "Write the launch post", identifier: "PAPER-102", status: "blocked", href: "/issues/PAPER-102" },
|
||||
whyNow: "The task needs an audience.", decisionVerbs: [], inlineResolvable: true, entryRule: "pending", exitRule: "answered",
|
||||
dedupKey: coordinatorTaskQuestion.id, dismissalKey: coordinatorTaskQuestion.id, dismissal: null, severity: "medium", rank: 1,
|
||||
activityAt: new Date(coordinatorTaskQuestion.createdAt).toISOString(), createdAt: new Date(coordinatorTaskQuestion.createdAt).toISOString(), updatedAt: new Date(coordinatorTaskQuestion.updatedAt).toISOString(),
|
||||
project: null, workspace: null, expiresAt: null, ruleKey: null, originAgentName: "Mira", queues: [], shelf: false,
|
||||
retentionDays: 0, keep: false, archivedAt: null, retentionVersion: 1, decideBy: null, decideByAttribution: null,
|
||||
snoozedUntil: null, detail: { kind: "questions", questionCount: 1, firstQuestionText: "Who is the primary reader?", images: [] }, trainingExampleId: null,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCombinedInboxTasksEnabled } from "./useCombinedInboxTasksEnabled";
|
||||
|
||||
vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn() } }));
|
||||
vi.mock("@/api/workspacePreferences", () => ({ workspacePreferencesApi: { get: vi.fn(), update: vi.fn() } }));
|
||||
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn() } }));
|
||||
|
||||
it("retains standalone Inbox in Coordinator and restores the combined preference after opting out", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "viewer" } });
|
||||
client.setQueryData(queryKeys.workspacePreferences.mine("viewer"), { userId: "viewer", coordinatorWorkspaceEnabled: true });
|
||||
client.setQueryData(queryKeys.instance.experimentalSettings, { enableStreamlinedUi: true, enableCombinedInboxTasks: true });
|
||||
const host = document.createElement("div"); document.body.append(host); const root = createRoot(host);
|
||||
function Probe() { const setting = useCombinedInboxTasksEnabled(); return <p>{setting.loaded ? String(setting.enabled) : "loading"}</p>; }
|
||||
try {
|
||||
flushSync(() => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
||||
await vi.waitFor(() => expect(host.textContent).toBe("false"));
|
||||
flushSync(() => client.setQueryData(queryKeys.workspacePreferences.mine("viewer"), { userId: "viewer", coordinatorWorkspaceEnabled: false }));
|
||||
await vi.waitFor(() => expect(host.textContent).toBe("true"));
|
||||
expect(client.getQueryData(queryKeys.instance.experimentalSettings)).toMatchObject({ enableCombinedInboxTasks: true });
|
||||
} finally { flushSync(() => root.unmount()); client.clear(); host.remove(); }
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { resolveStreamlinedUiEnabled } from "./useStreamlinedUiEnabled";
|
||||
import { useCoordinatorWorkspaceEnabled } from "./useCoordinatorWorkspaceEnabled";
|
||||
|
||||
/** On only when opted in and the streamlined shell (which hosts it) is on. */
|
||||
export function resolveCombinedInboxTasksEnabled(
|
||||
@@ -29,6 +30,7 @@ function getDetachedClient(): QueryClient {
|
||||
*/
|
||||
export function useCombinedInboxTasksEnabled(): { enabled: boolean; loaded: boolean } {
|
||||
const contextClient = useContext(QueryClientContext);
|
||||
const coordinator = useCoordinatorWorkspaceEnabled();
|
||||
const query = useQuery(
|
||||
{
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
@@ -41,7 +43,8 @@ export function useCombinedInboxTasksEnabled(): { enabled: boolean; loaded: bool
|
||||
if (!contextClient) return { enabled: false, loaded: true };
|
||||
|
||||
return {
|
||||
enabled: resolveCombinedInboxTasksEnabled(query.data),
|
||||
loaded: query.isFetched,
|
||||
// Coordinator keeps Inbox under More and owns its separate Tasks/Needs You IA.
|
||||
enabled: !coordinator.enabled && resolveCombinedInboxTasksEnabled(query.data),
|
||||
loaded: query.isFetched && coordinator.loaded,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCoordinatorWorkspaceEnabled } from "./useCoordinatorWorkspaceEnabled";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ session: vi.fn(), health: vi.fn(), get: vi.fn(), update: vi.fn() }));
|
||||
vi.mock("@/api/auth", () => ({ authApi: { getSession: mocks.session } }));
|
||||
vi.mock("@/api/health", () => ({ healthApi: { get: mocks.health } }));
|
||||
vi.mock("@/api/workspacePreferences", () => ({ workspacePreferencesApi: { get: mocks.get, update: mocks.update } }));
|
||||
|
||||
let current: ReturnType<typeof useCoordinatorWorkspaceEnabled>;
|
||||
function Probe() { current = useCoordinatorWorkspaceEnabled(); return <span>{String(current.enabled)}</span>; }
|
||||
const preference = (userId = "a", enabled = false) => ({ userId, coordinatorWorkspaceEnabled: enabled, updatedAt: null });
|
||||
const session = (userId: string) => ({ user: { id: userId } });
|
||||
async function flush() {
|
||||
for (let i = 0; i < 5; i++) await new Promise(resolve => window.setTimeout(resolve, 0));
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
describe("useCoordinatorWorkspaceEnabled", () => {
|
||||
let root: Root, container: HTMLDivElement, client: QueryClient;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.session.mockResolvedValue(session("a"));
|
||||
mocks.health.mockResolvedValue({ status: "ok", deploymentMode: "authenticated" });
|
||||
mocks.get.mockResolvedValue(preference());
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
|
||||
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(() => { flushSync(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
|
||||
async function mount(provider = true) {
|
||||
flushSync(() => root.render(provider ? <QueryClientProvider client={client}><Probe /></QueryClientProvider> : <Probe />));
|
||||
await flush();
|
||||
}
|
||||
|
||||
it("defaults off in detached hosts without fetching", async () => {
|
||||
await mount(false);
|
||||
expect(current).toMatchObject({ enabled: false, loaded: true });
|
||||
expect(mocks.get).not.toHaveBeenCalled(); expect(mocks.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it("switches only after save, and retains the saved choice when a later save fails", async () => {
|
||||
await mount();
|
||||
let resolve!: (value: ReturnType<typeof preference>) => void;
|
||||
mocks.update.mockImplementationOnce(() => new Promise(r => { resolve = r; }));
|
||||
const pending = current.setEnabled("company", true);
|
||||
await flush(); expect(current.enabled).toBe(false);
|
||||
resolve(preference("a", true)); await pending; await flush();
|
||||
expect(current.enabled).toBe(true);
|
||||
mocks.update.mockRejectedValueOnce(new Error("Save failed"));
|
||||
await expect(current.setEnabled("company", false)).rejects.toThrow("Save failed"); await flush();
|
||||
expect(current.enabled).toBe(true);
|
||||
});
|
||||
it("isolates cached choices and delayed save responses when accounts change", async () => {
|
||||
await mount();
|
||||
let resolve!: (value: ReturnType<typeof preference>) => void;
|
||||
mocks.update.mockImplementationOnce(() => new Promise(r => { resolve = r; }));
|
||||
const pending = current.setEnabled("company", true);
|
||||
mocks.get.mockResolvedValue(preference("b", false));
|
||||
client.setQueryData(queryKeys.auth.session, session("b")); await flush();
|
||||
resolve(preference("a", true)); await pending; await flush();
|
||||
expect(current).toMatchObject({ userId: "b", enabled: false });
|
||||
expect(client.getQueryData(queryKeys.workspacePreferences.mine("a"))).toMatchObject({ coordinatorWorkspaceEnabled: true });
|
||||
});
|
||||
it("loads the new account's saved choice while the previous account's real GET is still pending", async () => {
|
||||
const { workspacePreferencesApi } = await vi.importActual<typeof import("@/api/workspacePreferences")>("@/api/workspacePreferences");
|
||||
let resolvePrevious!: (response: Response) => void;
|
||||
const previous = new Promise<Response>(resolve => { resolvePrevious = resolve; });
|
||||
const fetchMock = vi.fn().mockReturnValueOnce(previous).mockResolvedValueOnce(Response.json(preference("b", true)));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mocks.get.mockImplementation(() => workspacePreferencesApi.get());
|
||||
await mount();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
try {
|
||||
client.setQueryData(queryKeys.auth.session, session("b"));
|
||||
await vi.waitFor(() => expect(current).toMatchObject({ userId: "b", enabled: true, loaded: true }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual(["/api/workspace-preferences/me", "/api/workspace-preferences/me"]);
|
||||
} finally {
|
||||
resolvePrevious(Response.json(preference("a", false)));
|
||||
await flush();
|
||||
}
|
||||
expect(current).toMatchObject({ userId: "b", enabled: true });
|
||||
expect(client.getQueryData(queryKeys.workspacePreferences.mine("a"))).toMatchObject(preference("a", false));
|
||||
expect(client.getQueryData(queryKeys.workspacePreferences.mine("b"))).toMatchObject(preference("b", true));
|
||||
});
|
||||
it("does not treat anonymous sessions as local board, even with an enabled cached local choice", async () => {
|
||||
mocks.session.mockResolvedValue(null);
|
||||
client.setQueryData(queryKeys.workspacePreferences.mine("local-board"), preference("local-board", true));
|
||||
await mount(); expect(current).toMatchObject({ userId: null, enabled: false, loaded: true });
|
||||
expect(mocks.get).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recognizes an explicitly local-trusted instance and fails closed on a read error", async () => {
|
||||
mocks.session.mockResolvedValue(null);
|
||||
mocks.health.mockResolvedValue({ status: "ok", deploymentMode: "local_trusted" });
|
||||
mocks.get.mockResolvedValue(preference("local-board", true));
|
||||
await mount(); expect(current).toMatchObject({ userId: "local-board", enabled: true });
|
||||
mocks.get.mockRejectedValue(new Error("Unavailable"));
|
||||
await client.invalidateQueries({ queryKey: queryKeys.workspacePreferences.mine("local-board") }); await flush();
|
||||
expect(current).toMatchObject({ enabled: false, loaded: true });
|
||||
});
|
||||
});
|
||||
Loaded 100 of 579 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user