mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Polish board settings and skills workflow (#4863)
## Thinking Path > - Paperclip's board UI and bundled skills are the operator layer for configuring agents, routines, issue workflows, and local troubleshooting loops. > - The prior rollup mixed this operator polish with database backups, backend reliability, thread scale, and cost/workflow primitives. > - This pull request isolates the remaining board QoL, settings, issue-detail integration, adapter config cleanup, and skills smoke tooling. > - It includes some integration-level overlap with the thread and workflow slices so this branch can run from `origin/master` while still preserving the full original work. > - Preferred merge order is the narrower primitives first, then this integration PR last. > - The benefit is that reviewers can inspect the user-facing board/settings/skills layer separately from backend infrastructure changes. ## What Changed - Added board/settings polish for agents, routines, company settings, project workspace detail, and issue detail controls. - Added agent/routine UI regression tests and New Issue dialog coverage. - Integrated issue-detail activity/cost/interaction surfaces and leaf work pause/resume controls. - Cleaned bundled adapter UI config defaults and onboarding copy. - Added terminal-bench loop and work-stoppage diagnosis skills plus a smoke test script. - Updated attachment type handling and Paperclip skill/API guidance. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/pages/Agents.test.tsx ui/src/pages/Routines.test.tsx ui/src/components/NewIssueDialog.test.tsx ui/src/pages/IssueDetail.test.tsx server/src/__tests__/costs-service.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts` - Result: 7 test files passed, 54 tests passed. - `pnpm run smoke:terminal-bench-loop-skill` - Result: JSON output included `"ok": true` and `"cleanup": true`. - UI screenshots not included because verification is focused component/page coverage for the changed board surfaces. ## Risks - This is the integration-heavy PR in the split and intentionally overlaps some component/API primitives with the issue-thread and workflow PRs so it can run from `origin/master`. - Preferred merge order: #4859, #4860, #4861, #4862, then this PR last. If earlier branches merge first, this PR may need a straightforward conflict refresh in shared UI files. - The terminal-bench smoke script creates temporary mock issues and relies on cleanup; the verified run returned `cleanup: true`. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium reasoning effort. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c4269bab59
commit
1fe1067361
28 changed files with 1718 additions and 173 deletions
|
|
@ -262,6 +262,7 @@ export function App() {
|
|||
<Route path="board-claim/:token" element={<BoardClaimPage />} />
|
||||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const companiesApi = {
|
|||
| "description"
|
||||
| "status"
|
||||
| "budgetMonthlyCents"
|
||||
| "attachmentMaxBytes"
|
||||
| "requireBoardApprovalForNewAgents"
|
||||
| "feedbackDataSharingEnabled"
|
||||
| "brandColor"
|
||||
|
|
|
|||
|
|
@ -784,28 +784,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</Field>
|
||||
)}
|
||||
|
||||
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
|
||||
{isLocal && isCreate && (
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={val!.promptTemplate}
|
||||
onChange={(v) => set!({ promptTemplate: v })}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = "agents/drafts/prompt-template";
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Adapter-specific fields are rendered inside Permissions & Configuration */}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,9 @@ interface IssueChatMessageContext {
|
|||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stopRunLabel?: string;
|
||||
stoppingRunLabel?: string;
|
||||
stopRunVariant?: "stop" | "pause";
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
onImageClick?: (src: string) => void;
|
||||
|
|
@ -137,6 +140,9 @@ interface IssueChatMessageContext {
|
|||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||
|
|
@ -273,6 +279,9 @@ interface IssueChatThreadProps {
|
|||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
onCancelRun?: () => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stopRunLabel?: string;
|
||||
stoppingRunLabel?: string;
|
||||
stopRunVariant?: "stop" | "pause";
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||||
draftKey?: string;
|
||||
|
|
@ -308,6 +317,9 @@ interface IssueChatThreadProps {
|
|||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
composerRef?: Ref<IssueChatComposerHandle>;
|
||||
/**
|
||||
* Hook for the parent to refetch comments when the user explicitly asks
|
||||
|
|
@ -1335,6 +1347,9 @@ function IssueChatAssistantMessage({
|
|||
onVote,
|
||||
agentMap,
|
||||
onStopRun,
|
||||
stopRunLabel = "Stop run",
|
||||
stoppingRunLabel = "Stopping...",
|
||||
stopRunVariant = "stop",
|
||||
} = useContext(IssueChatCtx);
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
|
|
@ -1528,13 +1543,21 @@ function IssueChatAssistantMessage({
|
|||
{canStopRun && onStopRun && runId ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isStoppingRun}
|
||||
className="text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200"
|
||||
className={cn(
|
||||
stopRunVariant === "pause"
|
||||
? "text-amber-700 focus:text-amber-800 dark:text-amber-300 dark:focus:text-amber-200"
|
||||
: "text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200",
|
||||
)}
|
||||
onSelect={() => {
|
||||
void onStopRun(runId);
|
||||
}}
|
||||
>
|
||||
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
|
||||
{isStoppingRun ? "Stopping..." : "Stop run"}
|
||||
{stopRunVariant === "pause" ? (
|
||||
<PauseCircle className="mr-2 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
|
||||
)}
|
||||
{isStoppingRun ? stoppingRunLabel : stopRunLabel}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{runHref ? (
|
||||
|
|
@ -1791,6 +1814,7 @@ function ExpiredRequestConfirmationActivity({
|
|||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onCancelInteraction,
|
||||
} = useContext(IssueChatCtx);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId);
|
||||
|
|
@ -1869,6 +1893,7 @@ function ExpiredRequestConfirmationActivity({
|
|||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1884,6 +1909,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
|||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
} = useContext(IssueChatCtx);
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
|
|
@ -1929,6 +1955,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
|||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -3061,6 +3088,9 @@ export function IssueChatThread({
|
|||
onAdd,
|
||||
onCancelRun,
|
||||
onStopRun,
|
||||
stopRunLabel,
|
||||
stoppingRunLabel,
|
||||
stopRunVariant,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
|
|
@ -3087,6 +3117,7 @@ export function IssueChatThread({
|
|||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
composerRef,
|
||||
onRefreshLatestComments,
|
||||
}: IssueChatThreadProps) {
|
||||
|
|
@ -3544,6 +3575,7 @@ export function IssueChatThread({
|
|||
const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction);
|
||||
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
|
||||
const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers);
|
||||
const stableOnCancelInteraction = useStableEvent(onCancelInteraction);
|
||||
|
||||
const chatCtx = useMemo<IssueChatMessageContext>(
|
||||
() => ({
|
||||
|
|
@ -3555,12 +3587,16 @@ export function IssueChatThread({
|
|||
userProfileMap,
|
||||
onVote: stableOnVote,
|
||||
onStopRun: stableOnStopRun,
|
||||
stopRunLabel,
|
||||
stoppingRunLabel,
|
||||
stopRunVariant,
|
||||
onInterruptQueued: stableOnInterruptQueued,
|
||||
onCancelQueued: stableOnCancelQueued,
|
||||
onImageClick: stableOnImageClick,
|
||||
onAcceptInteraction: stableOnAcceptInteraction,
|
||||
onRejectInteraction: stableOnRejectInteraction,
|
||||
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
|
||||
onCancelInteraction: stableOnCancelInteraction,
|
||||
}),
|
||||
[
|
||||
feedbackDataSharingPreference,
|
||||
|
|
@ -3571,12 +3607,16 @@ export function IssueChatThread({
|
|||
userProfileMap,
|
||||
stableOnVote,
|
||||
stableOnStopRun,
|
||||
stopRunLabel,
|
||||
stoppingRunLabel,
|
||||
stopRunVariant,
|
||||
stableOnInterruptQueued,
|
||||
stableOnCancelQueued,
|
||||
stableOnImageClick,
|
||||
stableOnAcceptInteraction,
|
||||
stableOnRejectInteraction,
|
||||
stableOnSubmitInteractionAnswers,
|
||||
stableOnCancelInteraction,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ export async function loadRemainingIssueCommentPages<T extends { id: string }>(p
|
|||
pages: ReadonlyArray<ReadonlyArray<T>> | undefined;
|
||||
pageParams: ReadonlyArray<string | null> | undefined;
|
||||
pageSize: number;
|
||||
maxPages?: number;
|
||||
fetchPage: (afterCommentId: string) => Promise<ReadonlyArray<T>>;
|
||||
}): Promise<{ pages: T[][]; pageParams: Array<string | null> }> {
|
||||
const pages = (params.pages ?? []).map((page) => [...page]);
|
||||
|
|
@ -176,8 +177,9 @@ export async function loadRemainingIssueCommentPages<T extends { id: string }>(p
|
|||
if (params.pageSize <= 0) return { pages, pageParams };
|
||||
|
||||
let cursor = getNextPageCursor(pages[pages.length - 1], params.pageSize);
|
||||
const maxPages = Math.max(0, params.maxPages ?? Number.POSITIVE_INFINITY);
|
||||
const seenCursors = new Set<string>();
|
||||
while (cursor && !seenCursors.has(cursor)) {
|
||||
while (cursor && !seenCursors.has(cursor) && seenCursors.size < maxPages) {
|
||||
seenCursors.add(cursor);
|
||||
const nextPage = [...await params.fetchPage(cursor)];
|
||||
pages.push(nextPage);
|
||||
|
|
|
|||
153
ui/src/pages/Agents.test.tsx
Normal file
153
ui/src/pages/Agents.test.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Agents } from "./Agents";
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
org: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||
liveRunsForCompany: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOpenNewAgent = vi.hoisted(() => vi.fn());
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/agents/all", search: "", hash: "", state: null }),
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialogActions: () => ({ openNewAgent: mockOpenNewAgent }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({ isMobile: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: mockAgentsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/heartbeats", () => ({
|
||||
heartbeatsApi: mockHeartbeatsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/adapter-display-registry", () => ({
|
||||
getAdapterLabel: (type: string) => type,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function makeAgent(overrides: Partial<Agent>): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Alpha",
|
||||
urlKey: "alpha",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Agents", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ adapterConfig: { model: "gpt-5.4" } }),
|
||||
]);
|
||||
mockAgentsApi.org.mockResolvedValue([
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "Alpha",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
reports: [],
|
||||
},
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const currentRoot = root;
|
||||
if (currentRoot) {
|
||||
await act(async () => {
|
||||
currentRoot.unmount();
|
||||
});
|
||||
}
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the configured model beside the adapter on the all agents page", async () => {
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Agents />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("codex_local");
|
||||
expect(container.textContent).toContain("gpt-5.4");
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,13 @@ function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean):
|
|||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getConfiguredModel(agent: Agent): string | null {
|
||||
const value = agent.adapterConfig?.model;
|
||||
if (typeof value !== "string") return null;
|
||||
const model = value.trim();
|
||||
return model.length > 0 ? model : null;
|
||||
}
|
||||
|
||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
|
||||
return nodes
|
||||
.reduce<OrgNode[]>((acc, node) => {
|
||||
|
|
@ -253,9 +260,15 @@ export function Agents() {
|
|||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
/>
|
||||
)}
|
||||
<span className="w-28 whitespace-nowrap text-right font-mono text-xs text-muted-foreground">
|
||||
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
<span
|
||||
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
|
||||
title={getConfiguredModel(agent) ?? undefined}
|
||||
>
|
||||
{getConfiguredModel(agent) ?? "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
|
|
@ -356,9 +369,15 @@ function OrgTreeNode({
|
|||
)}
|
||||
{agent && (
|
||||
<>
|
||||
<span className="w-28 whitespace-nowrap text-right font-mono text-xs text-muted-foreground">
|
||||
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
<span
|
||||
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
|
||||
title={getConfiguredModel(agent) ?? undefined}
|
||||
>
|
||||
{getConfiguredModel(agent) ?? "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
} from "@paperclipai/shared";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
|
|
@ -21,6 +25,9 @@ type AgentSnippetInput = {
|
|||
testResolutionUrl?: string | null;
|
||||
};
|
||||
|
||||
const BYTES_PER_MIB = 1024 * 1024;
|
||||
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
export function CompanySettings() {
|
||||
const {
|
||||
companies,
|
||||
|
|
@ -34,6 +41,7 @@ export function CompanySettings() {
|
|||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [brandColor, setBrandColor] = useState("");
|
||||
const [attachmentMaxMiB, setAttachmentMaxMiB] = useState(String(DEFAULT_COMPANY_ATTACHMENT_MAX_MIB));
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -43,6 +51,7 @@ export function CompanySettings() {
|
|||
setCompanyName(selectedCompany.name);
|
||||
setDescription(selectedCompany.description ?? "");
|
||||
setBrandColor(selectedCompany.brandColor ?? "");
|
||||
setAttachmentMaxMiB(String(Math.round((selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES) / BYTES_PER_MIB)));
|
||||
setLogoUrl(selectedCompany.logoUrl ?? "");
|
||||
}, [selectedCompany]);
|
||||
|
||||
|
|
@ -51,17 +60,25 @@ export function CompanySettings() {
|
|||
const [snippetCopied, setSnippetCopied] = useState(false);
|
||||
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
||||
|
||||
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
|
||||
const attachmentMaxValid =
|
||||
Number.isInteger(attachmentMaxBytes)
|
||||
&& attachmentMaxBytes >= BYTES_PER_MIB
|
||||
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
|
||||
const generalDirty =
|
||||
!!selectedCompany &&
|
||||
(companyName !== selectedCompany.name ||
|
||||
description !== (selectedCompany.description ?? "") ||
|
||||
brandColor !== (selectedCompany.brandColor ?? ""));
|
||||
brandColor !== (selectedCompany.brandColor ?? "") ||
|
||||
attachmentMaxBytes !== (selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES));
|
||||
|
||||
const generalMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
brandColor: string | null;
|
||||
attachmentMaxBytes: number;
|
||||
}) => companiesApi.update(selectedCompanyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
|
|
@ -214,7 +231,8 @@ export function CompanySettings() {
|
|||
generalMutation.mutate({
|
||||
name: companyName.trim(),
|
||||
description: description.trim() || null,
|
||||
brandColor: brandColor || null
|
||||
brandColor: brandColor || null,
|
||||
attachmentMaxBytes
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +364,30 @@ export function CompanySettings() {
|
|||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachment size limit"
|
||||
hint={`Accepted range: 1-${MAX_COMPANY_ATTACHMENT_MAX_MIB} MiB.`}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COMPANY_ATTACHMENT_MAX_MIB}
|
||||
step={1}
|
||||
value={attachmentMaxMiB}
|
||||
onChange={(e) => setAttachmentMaxMiB(e.target.value)}
|
||||
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">MiB</span>
|
||||
</div>
|
||||
{!attachmentMaxValid && (
|
||||
<span className="text-xs text-destructive">
|
||||
Enter a whole number from 1 to {MAX_COMPANY_ATTACHMENT_MAX_MIB}.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -357,7 +399,7 @@ export function CompanySettings() {
|
|||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveGeneral}
|
||||
disabled={generalMutation.isPending || !companyName.trim()}
|
||||
disabled={generalMutation.isPending || !companyName.trim() || !attachmentMaxValid}
|
||||
>
|
||||
{generalMutation.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
|||
const mockSetMobileToolbar = vi.hoisted(() => vi.fn());
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
const mockIssuesListRender = vi.hoisted(() => vi.fn());
|
||||
const mockIssueChatThreadRender = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
|
|
@ -190,7 +191,23 @@ vi.mock("../components/InlineEditor", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../components/IssueChatThread", () => ({
|
||||
IssueChatThread: () => <div data-testid="issue-chat-thread">Chat thread</div>,
|
||||
IssueChatThread: (props: {
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stopRunLabel?: string;
|
||||
stoppingRunLabel?: string;
|
||||
}) => {
|
||||
mockIssueChatThreadRender(props);
|
||||
return (
|
||||
<div data-testid="issue-chat-thread">
|
||||
Chat thread
|
||||
{props.onStopRun ? (
|
||||
<button type="button" onClick={() => void props.onStopRun?.("run-active-1")}>
|
||||
{props.stopRunLabel ?? "Stop run"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components/IssueDocumentsSection", () => ({
|
||||
|
|
@ -786,6 +803,7 @@ describe("IssueDetail", () => {
|
|||
feedbackDataSharingPreference: "prompt",
|
||||
});
|
||||
mockIssuesListRender.mockClear();
|
||||
mockIssueChatThreadRender.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -1036,6 +1054,161 @@ describe("IssueDetail", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("exposes leaf pause controls and routes issue active-run stop through Pause work", async () => {
|
||||
const pausePreview = createPausePreview();
|
||||
pausePreview.totals = {
|
||||
...pausePreview.totals,
|
||||
totalIssues: 1,
|
||||
affectedIssues: 1,
|
||||
skippedIssues: 0,
|
||||
activeRuns: 1,
|
||||
};
|
||||
pausePreview.issues = [pausePreview.issues[0]!];
|
||||
pausePreview.skippedIssues = [];
|
||||
const pauseHold = createPauseHold({
|
||||
id: "leaf-pause-hold-1",
|
||||
mode: "pause",
|
||||
reason: "Paused from active run controls.",
|
||||
releasePolicy: { strategy: "manual", note: "leaf_pause" },
|
||||
members: [],
|
||||
});
|
||||
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-1",
|
||||
executionRunId: "run-active-1",
|
||||
}));
|
||||
mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview);
|
||||
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: pauseHold, preview: pausePreview });
|
||||
mockAgentsApi.list.mockResolvedValue([createAgent()]);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { userId: "user-1" },
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
stopRunLabel: "Pause work",
|
||||
stoppingRunLabel: "Pausing...",
|
||||
});
|
||||
|
||||
const chatPauseButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Pause work");
|
||||
expect(chatPauseButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
chatPauseButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
|
||||
mode: "pause",
|
||||
reason: "Paused from active run controls.",
|
||||
releasePolicy: { strategy: "manual", note: "leaf_pause" },
|
||||
metadata: { source: "issue_active_run_control", runId: "run-active-1" },
|
||||
});
|
||||
|
||||
const moreButton = container.querySelector('button[aria-label="More issue actions"]') as HTMLButtonElement | null;
|
||||
expect(moreButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
moreButton!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const pauseMenuButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Pause work...");
|
||||
expect(pauseMenuButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => {
|
||||
const activeHold = createPauseHold();
|
||||
const releasedHold = createPauseHold({
|
||||
status: "released",
|
||||
releasedAt: new Date("2026-04-21T00:01:00.000Z"),
|
||||
releasedByActorType: "user",
|
||||
releasedByUserId: "user-1",
|
||||
releaseReason: "Ready to continue",
|
||||
updatedAt: new Date("2026-04-21T00:01:00.000Z"),
|
||||
});
|
||||
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
status: "in_review",
|
||||
assigneeAgentId: "agent-1",
|
||||
}));
|
||||
mockIssuesApi.getTreeControlState.mockResolvedValue({
|
||||
activePauseHold: {
|
||||
holdId: "hold-1",
|
||||
rootIssueId: "issue-1",
|
||||
issueId: "issue-1",
|
||||
isRoot: true,
|
||||
mode: "pause",
|
||||
reason: null,
|
||||
releasePolicy: { strategy: "manual", note: "leaf_pause" },
|
||||
},
|
||||
});
|
||||
mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]);
|
||||
mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview());
|
||||
mockIssuesApi.releaseTreeHold.mockResolvedValue(releasedHold);
|
||||
mockAgentsApi.list.mockResolvedValue([createAgent()]);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { userId: "user-1" },
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Paused by board.");
|
||||
expect(container.textContent).toContain("in_review");
|
||||
expect(container.textContent).not.toContain("Subtree pause is active.");
|
||||
});
|
||||
|
||||
const resumeButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Resume work");
|
||||
expect(resumeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resumeButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const wakeCheckbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
|
||||
expect(wakeCheckbox?.checked).toBe(true);
|
||||
|
||||
const applyResumeButton = Array.from(container.querySelectorAll("button"))
|
||||
.filter((button) => button.textContent?.trim() === "Resume work")
|
||||
.at(-1);
|
||||
expect(applyResumeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
applyResumeButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("PAP-1", "hold-1", {
|
||||
reason: null,
|
||||
metadata: { wakeAgents: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes restore subtree from the issue actions menu", async () => {
|
||||
const childIssue = createIssue({
|
||||
id: "child-1",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
flattenIssueCommentPages,
|
||||
getNextIssueCommentPageParam,
|
||||
isQueuedIssueComment,
|
||||
loadRemainingIssueCommentPages,
|
||||
matchesIssueRef,
|
||||
mergeIssueComments,
|
||||
removeIssueCommentFromPages,
|
||||
|
|
@ -130,6 +131,7 @@ import {
|
|||
isClosedIsolatedExecutionWorkspace,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
type AskUserQuestionsAnswer,
|
||||
type AskUserQuestionsInteraction,
|
||||
type ActivityEvent,
|
||||
type Agent,
|
||||
type FeedbackVote,
|
||||
|
|
@ -156,18 +158,39 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
|||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||
const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3;
|
||||
const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10;
|
||||
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
||||
pause: "Pause subtree",
|
||||
resume: "Resume subtree",
|
||||
cancel: "Cancel subtree",
|
||||
restore: "Restore subtree",
|
||||
};
|
||||
const LEAF_WORK_CONTROL_MODE_LABEL: Partial<Record<IssueTreeControlMode, string>> = {
|
||||
pause: "Pause work",
|
||||
resume: "Resume work",
|
||||
};
|
||||
const TREE_CONTROL_MODE_HELP_TEXT: Record<IssueTreeControlMode, string> = {
|
||||
pause: "Pause active execution in this issue subtree until an explicit resume.",
|
||||
resume: "Release the active subtree pause hold so held work can continue.",
|
||||
cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.",
|
||||
restore: "Restore issues cancelled by this subtree operation so work can resume.",
|
||||
};
|
||||
const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial<Record<IssueTreeControlMode, string>> = {
|
||||
pause: "Pause active execution on this issue until an explicit resume.",
|
||||
resume: "Release the active pause hold so this issue can continue.",
|
||||
};
|
||||
|
||||
function issueTreeControlLabel(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
|
||||
return scope === "leaf"
|
||||
? LEAF_WORK_CONTROL_MODE_LABEL[mode] ?? TREE_CONTROL_MODE_LABEL[mode]
|
||||
: TREE_CONTROL_MODE_LABEL[mode];
|
||||
}
|
||||
|
||||
function issueTreeControlHelpText(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
|
||||
return scope === "leaf"
|
||||
? LEAF_WORK_CONTROL_MODE_HELP_TEXT[mode] ?? TREE_CONTROL_MODE_HELP_TEXT[mode]
|
||||
: TREE_CONTROL_MODE_HELP_TEXT[mode];
|
||||
}
|
||||
|
||||
function treeControlPreviewErrorCopy(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
|
|
@ -586,8 +609,10 @@ type IssueDetailChatTabProps = {
|
|||
onImageUpload: (file: File) => Promise<string>;
|
||||
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
|
||||
onInterruptQueued: (runId: string) => Promise<void>;
|
||||
onPauseWorkRun?: (runId: string) => Promise<void>;
|
||||
onCancelQueued: (commentId: string) => void;
|
||||
interruptingQueuedRunId: string | null;
|
||||
pausingWorkRunId: string | null;
|
||||
onImageClick: (src: string) => void;
|
||||
onAcceptInteraction: (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
|
|
@ -598,6 +623,7 @@ type IssueDetailChatTabProps = {
|
|||
interaction: IssueThreadInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void>;
|
||||
onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise<void>;
|
||||
};
|
||||
|
||||
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
|
|
@ -636,12 +662,15 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
onImageUpload,
|
||||
onAttachImage,
|
||||
onInterruptQueued,
|
||||
onPauseWorkRun,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
pausingWorkRunId,
|
||||
onImageClick,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
}: IssueDetailChatTabProps) {
|
||||
const { data: activity } = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
|
|
@ -826,16 +855,20 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
onInterruptQueued={onInterruptQueued}
|
||||
onCancelQueued={onCancelQueued}
|
||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||
stoppingRunId={interruptingQueuedRunId}
|
||||
onStopRun={onInterruptQueued}
|
||||
stoppingRunId={pausingWorkRunId}
|
||||
onStopRun={onPauseWorkRun}
|
||||
stopRunLabel="Pause work"
|
||||
stoppingRunLabel="Pausing..."
|
||||
stopRunVariant="pause"
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={(interaction, answers) =>
|
||||
onSubmitInteractionAnswers(interaction, answers)
|
||||
}
|
||||
onCancelRun={runningIssueRun
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
onCancelRun={runningIssueRun && onPauseWorkRun
|
||||
? async () => {
|
||||
await onInterruptQueued(runningIssueRun.id);
|
||||
await onPauseWorkRun(runningIssueRun.id);
|
||||
}
|
||||
: undefined}
|
||||
onImageClick={onImageClick}
|
||||
|
|
@ -902,6 +935,11 @@ function IssueDetailActivityTab({
|
|||
issueId,
|
||||
),
|
||||
});
|
||||
const { data: issueTreeCostSummary } = useQuery({
|
||||
queryKey: queryKeys.issues.costSummary(issueId),
|
||||
queryFn: () => issuesApi.getCostSummary(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.getCostSummary>>>(issueId),
|
||||
});
|
||||
const initialLoading =
|
||||
(activityLoading && activity === undefined)
|
||||
|| (linkedRunsLoading && linkedRuns === undefined);
|
||||
|
|
@ -943,6 +981,16 @@ function IssueDetailActivityTab({
|
|||
hasTokens,
|
||||
};
|
||||
}, [linkedRuns]);
|
||||
const issueTreeCostTokens =
|
||||
(issueTreeCostSummary?.inputTokens ?? 0) + (issueTreeCostSummary?.outputTokens ?? 0);
|
||||
const hasIssueTreeCost =
|
||||
!!issueTreeCostSummary
|
||||
&& (issueTreeCostSummary.costCents > 0
|
||||
|| issueTreeCostTokens > 0
|
||||
|| issueTreeCostSummary.cachedInputTokens > 0
|
||||
|| issueTreeCostSummary.issueCount > 1);
|
||||
const shouldShowCostSummary =
|
||||
(linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost;
|
||||
|
||||
if (initialLoading) {
|
||||
return <IssueSectionSkeleton titleWidth="w-20" rows={4} />;
|
||||
|
|
@ -950,6 +998,55 @@ function IssueDetailActivityTab({
|
|||
|
||||
return (
|
||||
<>
|
||||
{shouldShowCostSummary && (
|
||||
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
|
||||
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !hasIssueTreeCost ? (
|
||||
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
||||
) : (
|
||||
<div className="space-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="font-medium text-foreground">This issue</span>
|
||||
{issueCostSummary.hasCost ? (
|
||||
<span className="font-medium text-foreground">
|
||||
${issueCostSummary.cost.toFixed(4)}
|
||||
</span>
|
||||
) : null}
|
||||
{issueCostSummary.hasTokens ? (
|
||||
<span>
|
||||
Tokens {formatTokens(issueCostSummary.totalTokens)}
|
||||
{issueCostSummary.cached > 0
|
||||
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
|
||||
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
|
||||
</span>
|
||||
) : null}
|
||||
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
|
||||
<span>No direct cost data.</span>
|
||||
) : null}
|
||||
</div>
|
||||
{hasIssueTreeCost && issueTreeCostSummary ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="font-medium text-foreground">
|
||||
Including sub-issues {(issueTreeCostSummary.costCents / 100).toLocaleString(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
Tokens {formatTokens(issueTreeCostTokens)}
|
||||
{issueTreeCostSummary.cachedInputTokens > 0
|
||||
? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})`
|
||||
: ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`}
|
||||
</span>
|
||||
<span>{issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<IssueRunLedger
|
||||
issueId={issueId}
|
||||
|
|
@ -958,9 +1055,19 @@ function IssueDetailActivityTab({
|
|||
childIssues={childIssues}
|
||||
agentMap={agentMap}
|
||||
hasLiveRuns={hasLiveRuns}
|
||||
activityEvents={activity ?? []}
|
||||
renderActivityEvent={(evt) => (
|
||||
<div className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
|
||||
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
|
||||
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
||||
</div>
|
||||
<IssueReferenceActivitySummary event={evt} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
|
||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||
<div className="mb-3 space-y-3">
|
||||
{linkedApprovals.map((approval) => (
|
||||
|
|
@ -981,46 +1088,7 @@ function IssueDetailActivityTab({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
|
||||
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
|
||||
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
|
||||
{issueCostSummary.hasCost && (
|
||||
<span className="font-medium text-foreground">
|
||||
${issueCostSummary.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
{issueCostSummary.hasTokens && (
|
||||
<span>
|
||||
Tokens {formatTokens(issueCostSummary.totalTokens)}
|
||||
{issueCostSummary.cached > 0
|
||||
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
|
||||
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!activity || activity.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{activity.slice(0, 20).map((evt) => (
|
||||
<div key={evt.id} className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
|
||||
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
|
||||
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
||||
</div>
|
||||
<IssueReferenceActivitySummary event={evt} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1560,7 +1628,7 @@ export function IssueDetail() {
|
|||
reason: treeControlReason.trim() || null,
|
||||
releasePolicy: {
|
||||
strategy: "manual",
|
||||
...(treeControlMode === "pause" ? { note: "full_pause" } : {}),
|
||||
...(treeControlMode === "pause" ? { note: treeControlScope === "leaf" ? "leaf_pause" : "full_pause" } : {}),
|
||||
},
|
||||
...(treeControlMode === "restore"
|
||||
? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } }
|
||||
|
|
@ -1569,18 +1637,20 @@ export function IssueDetail() {
|
|||
return { kind: "create" as const, hold: created.hold, preview: created.preview };
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
const modeLabel = TREE_CONTROL_MODE_LABEL[result.hold.mode];
|
||||
const modeLabel = issueTreeControlLabel(result.hold.mode, treeControlScope);
|
||||
const cancelCount = result.preview?.totals.activeRuns ?? 0;
|
||||
pushToast({
|
||||
title: result.kind === "release"
|
||||
? "Subtree resumed"
|
||||
? treeControlScope === "leaf" ? "Work resumed" : "Subtree resumed"
|
||||
: result.hold.mode === "pause"
|
||||
? "Subtree paused"
|
||||
? treeControlScope === "leaf" ? "Work paused" : "Subtree paused"
|
||||
: `${modeLabel} applied`,
|
||||
body: result.kind === "release"
|
||||
? (result.hold.releaseReason?.trim() || "Active subtree pause released.")
|
||||
? (result.hold.releaseReason?.trim() || (treeControlScope === "leaf" ? "Active issue pause released." : "Active subtree pause released."))
|
||||
: result.hold.mode === "pause"
|
||||
? `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
? treeControlScope === "leaf"
|
||||
? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
: `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
: result.hold.reason?.trim()
|
||||
? result.hold.reason
|
||||
: "Subtree control applied.",
|
||||
|
|
@ -1591,6 +1661,7 @@ export function IssueDetail() {
|
|||
setTreeControlCancelConfirmed(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
|
||||
|
|
@ -1602,7 +1673,10 @@ export function IssueDetail() {
|
|||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }),
|
||||
...(issue?.id
|
||||
? [queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) })]
|
||||
? [
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByDescendantRoot(selectedCompanyId, issue.id) }),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
|
@ -1615,6 +1689,45 @@ export function IssueDetail() {
|
|||
});
|
||||
},
|
||||
});
|
||||
const pauseIssueWorkRun = useMutation({
|
||||
mutationFn: async ({ runId, scope }: { runId: string; scope: "leaf" | "subtree" }) => {
|
||||
const created = await issuesApi.createTreeHold(issueId!, {
|
||||
mode: "pause",
|
||||
reason: "Paused from active run controls.",
|
||||
releasePolicy: { strategy: "manual", note: scope === "leaf" ? "leaf_pause" : "full_pause" },
|
||||
metadata: { source: "issue_active_run_control", runId },
|
||||
});
|
||||
return created;
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
const cancelCount = result.preview?.totals.activeRuns ?? 0;
|
||||
pushToast({
|
||||
title: "Work paused",
|
||||
body: cancelCount > 0
|
||||
? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
: "Work paused. This issue is held until resume.",
|
||||
tone: "success",
|
||||
});
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-holds", issueId ?? "pending"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-preview", issueId ?? "pending"] }),
|
||||
]);
|
||||
invalidateIssueCollections();
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Unable to pause work",
|
||||
body: err instanceof Error ? err.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
||||
updateIssue.mutate(data);
|
||||
}, [updateIssue.mutate]);
|
||||
|
|
@ -1859,6 +1972,27 @@ export function IssueDetail() {
|
|||
},
|
||||
});
|
||||
|
||||
const cancelInteraction = useMutation({
|
||||
mutationFn: ({ interaction }: { interaction: AskUserQuestionsInteraction }) =>
|
||||
issuesApi.cancelInteraction(issueId!, interaction.id),
|
||||
onSuccess: (interaction) => {
|
||||
upsertInteractionInCache(interaction);
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueCollections();
|
||||
pushToast({
|
||||
title: "Question cancelled",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Cancel failed",
|
||||
body: err instanceof Error ? err.message : "Unable to cancel the question",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const addCommentAndReassign = useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
|
|
@ -2561,12 +2695,35 @@ export function IssueDetail() {
|
|||
void fetchOlderComments();
|
||||
}, [fetchOlderComments]);
|
||||
const refetchLatestComments = useCallback(async () => {
|
||||
// Refetch the entire infinite-query (page 0 first), so any comments that
|
||||
// arrived after the initial load — including ones live updates may have
|
||||
// missed during reconnects — are present before we scroll the user to
|
||||
// the absolute newest.
|
||||
await refetchComments();
|
||||
}, [refetchComments]);
|
||||
// Refetch page 0 first so comments that arrived after initial load are
|
||||
// visible, then load every remaining older page. The chat thread is
|
||||
// paginated and virtualized, so "latest" must be resolved against the
|
||||
// complete comment set rather than the current loaded window.
|
||||
const refreshed = await refetchComments();
|
||||
const loaded = await loadRemainingIssueCommentPages<IssueComment>({
|
||||
pages: refreshed.data?.pages,
|
||||
pageParams: refreshed.data?.pageParams as Array<string | null> | undefined,
|
||||
pageSize: ISSUE_COMMENT_PAGE_SIZE,
|
||||
maxPages: JUMP_TO_LATEST_MAX_COMMENT_PAGES,
|
||||
fetchPage: (afterCommentId) =>
|
||||
issuesApi.listComments(issueId!, {
|
||||
order: "desc",
|
||||
limit: ISSUE_COMMENT_PAGE_SIZE,
|
||||
after: afterCommentId,
|
||||
}),
|
||||
});
|
||||
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
loaded,
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
if (typeof window === "undefined") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}, [issueId, queryClient, refetchComments]);
|
||||
useEffect(() => {
|
||||
if (!shouldPrefetchOlderComments) return;
|
||||
void fetchOlderComments();
|
||||
|
|
@ -2613,6 +2770,9 @@ export function IssueDetail() {
|
|||
) => {
|
||||
await answerInteraction.mutateAsync({ interaction, answers });
|
||||
}, [answerInteraction]);
|
||||
const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => {
|
||||
await cancelInteraction.mutateAsync({ interaction });
|
||||
}, [cancelInteraction]);
|
||||
|
||||
const treePreviewAffectedIssues = useMemo(
|
||||
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
|
||||
|
|
@ -2710,16 +2870,25 @@ export function IssueDetail() {
|
|||
const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0;
|
||||
const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true;
|
||||
const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0;
|
||||
const isTerminalIssue = issue.status === "done" || issue.status === "cancelled";
|
||||
const isAgentOwnedNonTerminalIssue = Boolean(issue.assigneeAgentId) && !isTerminalIssue;
|
||||
const canPauseLeafWork = canManageTreeControl && childIssues.length === 0 && !activePauseHold && !isTerminalIssue;
|
||||
const canResumeLeafWork = canManageTreeControl && childIssues.length === 0 && activePauseHold?.isRoot === true;
|
||||
const treeControlScope: "leaf" | "subtree" = childIssues.length === 0 ? "leaf" : "subtree";
|
||||
const previewAffectedIssueCount = treePreviewAffectedIssues.length;
|
||||
const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0;
|
||||
const treeControlPrimaryButtonLabel =
|
||||
treeControlMode === "pause"
|
||||
? "Pause and stop work"
|
||||
? treeControlScope === "leaf"
|
||||
? "Pause work"
|
||||
: "Pause and stop work"
|
||||
: treeControlMode === "cancel"
|
||||
? `Cancel ${previewAffectedIssueCount} issues`
|
||||
: treeControlMode === "restore"
|
||||
? `Restore ${previewAffectedIssueCount} issues`
|
||||
: "Resume subtree";
|
||||
: treeControlScope === "leaf"
|
||||
? "Resume work"
|
||||
: "Resume subtree";
|
||||
const treePreviewAffectedIssueRows = treePreviewDisplayIssues.map((candidate) => ({
|
||||
candidate,
|
||||
issue: {
|
||||
|
|
@ -2748,7 +2917,7 @@ export function IssueDetail() {
|
|||
)
|
||||
: null;
|
||||
const composerHint = pausedComposerHint;
|
||||
const queuedCommentReason: "hold" | "active_run" | "other" = "active_run";
|
||||
const queuedCommentReason: "hold" | "active_run" | "other" = activePauseHold ? "hold" : "active_run";
|
||||
const canApplyTreeControl =
|
||||
Boolean(treeControlPreview)
|
||||
&& !treeControlPreviewLoading
|
||||
|
|
@ -2823,50 +2992,58 @@ export function IssueDetail() {
|
|||
{activePauseHold.isRoot ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">Subtree pause is active.</span>
|
||||
<span className="font-medium">
|
||||
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
|
||||
</span>
|
||||
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
Root and descendant execution is held until resume. Human comments can still wake assignees for triage.
|
||||
{childIssues.length === 0
|
||||
? "Issue execution is held until resume. Human comments can still wake the assignee for triage."
|
||||
: "Root and descendant execution is held until resume. Human comments can still wake assignees for triage."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
{heldDescendantCount} descendant{heldDescendantCount === 1 ? "" : "s"} held
|
||||
{childIssues.length === 0
|
||||
? "1 issue held"
|
||||
: `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`}
|
||||
{activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
|
||||
</div>
|
||||
{canShowSubtreeControls ? (
|
||||
{canShowSubtreeControls || canResumeLeafWork ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(true);
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
Resume subtree
|
||||
{childIssues.length === 0 ? "Resume work" : "Resume subtree"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(true);
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
View affected ({heldDescendantCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setTreeControlMode("cancel");
|
||||
setTreeControlCancelConfirmed(false);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
Cancel subtree...
|
||||
View affected ({childIssues.length === 0 ? 1 : heldDescendantCount})
|
||||
</Button>
|
||||
{canShowSubtreeControls ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setTreeControlMode("cancel");
|
||||
setTreeControlCancelConfirmed(false);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
Cancel subtree...
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -3045,6 +3222,34 @@ export function IssueDetail() {
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-1" align="end">
|
||||
{canPauseLeafWork ? (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setTreeControlMode("pause");
|
||||
setTreeControlCancelConfirmed(false);
|
||||
setTreeControlOpen(true);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<PauseCircle className="h-3 w-3" />
|
||||
Pause work...
|
||||
</button>
|
||||
) : null}
|
||||
{canResumeLeafWork ? (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue);
|
||||
setTreeControlOpen(true);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="h-3 w-3" />
|
||||
Resume work
|
||||
</button>
|
||||
) : null}
|
||||
{canShowSubtreeControls ? (
|
||||
<>
|
||||
<button
|
||||
|
|
@ -3451,12 +3656,17 @@ export function IssueDetail() {
|
|||
onImageUpload={handleCommentImageUpload}
|
||||
onAttachImage={handleCommentAttachImage}
|
||||
onInterruptQueued={handleInterruptQueuedRun}
|
||||
onPauseWorkRun={canManageTreeControl
|
||||
? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined)
|
||||
: undefined}
|
||||
onCancelQueued={handleCancelQueuedComment}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
||||
pausingWorkRunId={pauseIssueWorkRun.isPending ? pauseIssueWorkRun.variables?.runId ?? null : null}
|
||||
onImageClick={handleChatImageClick}
|
||||
onAcceptInteraction={handleAcceptInteraction}
|
||||
onRejectInteraction={handleRejectInteraction}
|
||||
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
|
||||
onCancelInteraction={handleCancelInteraction}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
|
@ -3504,9 +3714,9 @@ export function IssueDetail() {
|
|||
<Dialog open={treeControlOpen} onOpenChange={setTreeControlOpen}>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[560px]">
|
||||
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
|
||||
<DialogTitle>{TREE_CONTROL_MODE_LABEL[treeControlMode]}</DialogTitle>
|
||||
<DialogTitle>{issueTreeControlLabel(treeControlMode, treeControlScope)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{TREE_CONTROL_MODE_HELP_TEXT[treeControlMode]}
|
||||
{issueTreeControlHelpText(treeControlMode, treeControlScope)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -317,9 +317,9 @@ export function ProjectWorkspaceDetail() {
|
|||
request.action === "run"
|
||||
? "Workspace job completed."
|
||||
: request.action === "stop"
|
||||
? "Workspace service stopped."
|
||||
? "Workspace service stopped. Issue execution is not paused."
|
||||
: request.action === "restart"
|
||||
? "Workspace service restarted."
|
||||
? "Workspace service restarted. Issue execution is not paused."
|
||||
: "Workspace service started.",
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, RoutineListItem } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Routines, buildRoutineGroups } from "./Routines";
|
||||
import { Routines, buildRoutineGroups, sortRoutines } from "./Routines";
|
||||
|
||||
let currentSearch = "";
|
||||
|
||||
|
|
@ -357,6 +357,97 @@ describe("Routines page", () => {
|
|||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
|
||||
});
|
||||
|
||||
it("sorts routines by selected field and direction without mutating the source list", () => {
|
||||
const routines = [
|
||||
createRoutine({
|
||||
id: "routine-1",
|
||||
title: "Weekly digest",
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-03T00:00:00.000Z"),
|
||||
lastRun: {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
routineId: "routine-1",
|
||||
triggerId: null,
|
||||
source: "manual",
|
||||
status: "succeeded",
|
||||
triggeredAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
idempotencyKey: null,
|
||||
triggerPayload: null,
|
||||
dispatchFingerprint: null,
|
||||
linkedIssueId: null,
|
||||
coalescedIntoRunId: null,
|
||||
failureReason: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
linkedIssue: null,
|
||||
trigger: null,
|
||||
},
|
||||
}),
|
||||
createRoutine({
|
||||
id: "routine-2",
|
||||
title: "Morning sync",
|
||||
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-04T00:00:00.000Z"),
|
||||
lastRun: null,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(sortRoutines(routines, "title", "asc").map((routine) => routine.title)).toEqual([
|
||||
"Morning sync",
|
||||
"Weekly digest",
|
||||
]);
|
||||
expect(sortRoutines(routines, "updated", "desc").map((routine) => routine.id)).toEqual([
|
||||
"routine-2",
|
||||
"routine-1",
|
||||
]);
|
||||
expect(sortRoutines(routines, "lastRun", "desc").map((routine) => routine.id)).toEqual([
|
||||
"routine-1",
|
||||
"routine-2",
|
||||
]);
|
||||
expect(routines.map((routine) => routine.id)).toEqual(["routine-1", "routine-2"]);
|
||||
});
|
||||
|
||||
it("renders the routines sort control before the group control", async () => {
|
||||
routinesListMock.mockResolvedValue([]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
let sortButton = container.querySelector<HTMLButtonElement>('button[title="Sort"]');
|
||||
let groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
|
||||
for (let attempts = 0; attempts < 5 && (!sortButton || !groupButton); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
sortButton = container.querySelector<HTMLButtonElement>('button[title="Sort"]');
|
||||
groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
|
||||
}
|
||||
|
||||
expect(sortButton).not.toBeNull();
|
||||
expect(groupButton).not.toBeNull();
|
||||
expect(sortButton!.compareDocumentPosition(groupButton!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("passes company mention options to the routine description editor", async () => {
|
||||
routinesListMock.mockResolvedValue([]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
|
|
@ -83,8 +83,12 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
|||
|
||||
type RoutinesTab = "routines" | "runs";
|
||||
type RoutineGroupBy = "none" | "project" | "assignee";
|
||||
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
|
||||
type RoutineSortDir = "asc" | "desc";
|
||||
|
||||
type RoutineViewState = {
|
||||
sortField: RoutineSortField;
|
||||
sortDir: RoutineSortDir;
|
||||
groupBy: RoutineGroupBy;
|
||||
collapsedGroups: string[];
|
||||
};
|
||||
|
|
@ -96,6 +100,8 @@ type RoutineGroup = {
|
|||
};
|
||||
|
||||
const defaultRoutineViewState: RoutineViewState = {
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
groupBy: "none",
|
||||
collapsedGroups: [],
|
||||
};
|
||||
|
|
@ -114,6 +120,16 @@ function saveRoutineViewState(key: string, state: RoutineViewState) {
|
|||
localStorage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function timestampValue(value: Date | string | null | undefined) {
|
||||
if (!value) return Number.NEGATIVE_INFINITY;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
function compareNullableText(left: string | null | undefined, right: string | null | undefined) {
|
||||
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
|
||||
}
|
||||
|
||||
function formatRoutineRunStatus(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value.replaceAll("_", " ");
|
||||
|
|
@ -176,6 +192,31 @@ export function buildRoutineGroups(
|
|||
}));
|
||||
}
|
||||
|
||||
export function sortRoutines(
|
||||
routines: RoutineListItem[],
|
||||
sortField: RoutineSortField,
|
||||
sortDir: RoutineSortDir,
|
||||
): RoutineListItem[] {
|
||||
const direction = sortDir === "asc" ? 1 : -1;
|
||||
return [...routines].sort((left, right) => {
|
||||
let result = 0;
|
||||
|
||||
if (sortField === "title") {
|
||||
result = compareNullableText(left.title, right.title);
|
||||
} else if (sortField === "created") {
|
||||
result = timestampValue(left.createdAt) - timestampValue(right.createdAt);
|
||||
} else if (sortField === "lastRun") {
|
||||
result = timestampValue(left.lastRun?.triggeredAt ?? left.lastTriggeredAt) -
|
||||
timestampValue(right.lastRun?.triggeredAt ?? right.lastTriggeredAt);
|
||||
} else {
|
||||
result = timestampValue(left.updatedAt) - timestampValue(right.updatedAt);
|
||||
}
|
||||
|
||||
if (result !== 0) return result * direction;
|
||||
return compareNullableText(left.title, right.title);
|
||||
});
|
||||
}
|
||||
|
||||
function buildRoutinesTabHref(tab: RoutinesTab) {
|
||||
return tab === "runs" ? "/routines?tab=runs" : "/routines";
|
||||
}
|
||||
|
|
@ -509,9 +550,13 @@ export function Routines() {
|
|||
[projects],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
const sortedRoutines = useMemo(
|
||||
() => sortRoutines(routines ?? [], routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, routines],
|
||||
);
|
||||
const routineGroups = useMemo(
|
||||
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
|
||||
[agentById, projectById, routineViewState.groupBy, routines],
|
||||
() => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
[agentById, projectById, routineViewState.groupBy, sortedRoutines],
|
||||
);
|
||||
const recentRunsIssueLinkState = useMemo(
|
||||
() =>
|
||||
|
|
@ -606,36 +651,79 @@ export function Routines() {
|
|||
<p className="text-sm text-muted-foreground">
|
||||
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Group</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{([
|
||||
["project", "Project"],
|
||||
["assignee", "Agent"],
|
||||
["none", "None"],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
|
||||
routineViewState.groupBy === value
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs" title="Sort">
|
||||
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Sort</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{([
|
||||
["updated", "Updated"],
|
||||
["created", "Created"],
|
||||
["lastRun", "Last run"],
|
||||
["title", "Title"],
|
||||
] as const).map(([field, label]) => (
|
||||
<button
|
||||
key={field}
|
||||
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
|
||||
routineViewState.sortField === field
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateRoutineView(
|
||||
routineViewState.sortField === field
|
||||
? { sortDir: routineViewState.sortDir === "asc" ? "desc" : "asc" }
|
||||
: { sortField: field, sortDir: field === "title" ? "asc" : "desc" },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{routineViewState.sortField === field ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{routineViewState.sortDir === "asc" ? "Asc" : "Desc"}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs" title="Group">
|
||||
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Group</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{([
|
||||
["project", "Project"],
|
||||
["assignee", "Agent"],
|
||||
["none", "None"],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
|
||||
routineViewState.groupBy === value
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="runs">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue