Merge upstream/master into dev (76 commits)
Resolved 5 conflicts: - .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev) - server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events - server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard, layered before upstream's soft-delete + provider cleanup in remove() - ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
+7
-1
@@ -13,6 +13,7 @@ import { ProjectDetail } from "./pages/ProjectDetail";
|
||||
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
|
||||
import { Workspaces } from "./pages/Workspaces";
|
||||
import { Issues } from "./pages/Issues";
|
||||
import { Search } from "./pages/Search";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
|
||||
import { Routines } from "./pages/Routines";
|
||||
@@ -32,6 +33,7 @@ import { CompanyAccess } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySecrets } from "./pages/CompanySecrets";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { Secrets } from "./pages/Secrets";
|
||||
import { CompanyExport } from "./pages/CompanyExport";
|
||||
import { CompanyImport } from "./pages/CompanyImport";
|
||||
import { DesignGuide } from "./pages/DesignGuide";
|
||||
@@ -72,6 +74,7 @@ function boardRoutes() {
|
||||
<Route path="company/settings/secrets" element={<CompanySecrets />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="company/settings/secrets" element={<Secrets />} />
|
||||
<Route path="skills/*" element={<CompanySkills />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
@@ -97,6 +100,7 @@ function boardRoutes() {
|
||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||
<Route path="workspaces" element={<Workspaces />} />
|
||||
<Route path="issues" element={<Issues />} />
|
||||
<Route path="search" element={<Search />} />
|
||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
|
||||
@@ -109,6 +113,7 @@ function boardRoutes() {
|
||||
<Route path="routines" element={<Routines />} />
|
||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||
@@ -131,7 +136,7 @@ function boardRoutes() {
|
||||
<Route path="u/:userSlug" element={<UserProfile />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
<Route path=":pluginRoutePath/*" element={<PluginPage />} />
|
||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||
</>
|
||||
);
|
||||
@@ -306,6 +311,7 @@ export function App() {
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
||||
|
||||
@@ -98,6 +98,11 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
||||
description: "Local Cursor agent",
|
||||
icon: MousePointer2,
|
||||
},
|
||||
cursor_cloud: {
|
||||
label: "Cursor Cloud",
|
||||
description: "Managed remote Cursor agent",
|
||||
icon: MousePointer2,
|
||||
},
|
||||
openclaw_gateway: {
|
||||
label: "OpenClaw Gateway",
|
||||
description: "Invoke OpenClaw via gateway protocol",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { SchemaConfigFields } from "../schema-config-fields";
|
||||
import {
|
||||
buildCursorCloudConfig,
|
||||
parseCursorCloudStdoutLine,
|
||||
} from "@paperclipai/adapter-cursor-cloud/ui";
|
||||
|
||||
export const cursorCloudUIAdapter: UIAdapterModule = {
|
||||
type: "cursor_cloud",
|
||||
label: "Cursor Cloud",
|
||||
parseStdoutLine: parseCursorCloudStdoutLine,
|
||||
ConfigFields: SchemaConfigFields,
|
||||
buildAdapterConfig: buildCursorCloudConfig,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { UIAdapterModule } from "./types";
|
||||
import { acpxLocalUIAdapter } from "./acpx-local";
|
||||
import { claudeLocalUIAdapter } from "./claude-local";
|
||||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorCloudUIAdapter } from "./cursor-cloud";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
@@ -53,6 +54,7 @@ function registerBuiltInUIAdapters() {
|
||||
acpxLocalUIAdapter,
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
cursorCloudUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
hermesLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdapterConfigSchema, ConfigFieldSchema } from "@paperclipai/adapter-utils";
|
||||
import { fieldMatchesVisibleWhen } from "./schema-config-fields";
|
||||
|
||||
const sourceField: ConfigFieldSchema = {
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Claude", value: "claude" },
|
||||
{ label: "Codex", value: "codex" },
|
||||
],
|
||||
};
|
||||
|
||||
const schema: AdapterConfigSchema = {
|
||||
fields: [sourceField],
|
||||
};
|
||||
|
||||
function targetWithVisibleWhen(visibleWhen: Record<string, unknown>): ConfigFieldSchema {
|
||||
return {
|
||||
key: "model",
|
||||
label: "Model",
|
||||
type: "text",
|
||||
meta: { visibleWhen },
|
||||
};
|
||||
}
|
||||
|
||||
describe("fieldMatchesVisibleWhen", () => {
|
||||
it("treats an empty values array as no match", () => {
|
||||
const field = targetWithVisibleWhen({ key: "provider", values: [] });
|
||||
|
||||
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats all non-string values as no match", () => {
|
||||
const field = targetWithVisibleWhen({ key: "provider", values: [null, 42] });
|
||||
|
||||
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches non-empty string values", () => {
|
||||
const field = targetWithVisibleWhen({ key: "provider", values: ["claude"] });
|
||||
|
||||
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(true);
|
||||
expect(fieldMatchesVisibleWhen(field, () => "codex", schema)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -283,6 +283,38 @@ function getDefaultValue(field: ConfigFieldSchema): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
export function fieldMatchesVisibleWhen(
|
||||
field: ConfigFieldSchema,
|
||||
readValue: (field: ConfigFieldSchema) => unknown,
|
||||
schema: AdapterConfigSchema,
|
||||
): boolean {
|
||||
const visibleWhen = field.meta?.visibleWhen;
|
||||
if (!visibleWhen || typeof visibleWhen !== "object" || Array.isArray(visibleWhen)) return true;
|
||||
|
||||
const condition = visibleWhen as {
|
||||
key?: unknown;
|
||||
value?: unknown;
|
||||
values?: unknown;
|
||||
notValues?: unknown;
|
||||
};
|
||||
if (typeof condition.key !== "string" || condition.key.length === 0) return true;
|
||||
|
||||
const sourceField = schema.fields.find((candidate) => candidate.key === condition.key);
|
||||
if (!sourceField) return true;
|
||||
|
||||
const actual = String(readValue(sourceField) ?? "");
|
||||
if (typeof condition.value === "string") return actual === condition.value;
|
||||
if (Array.isArray(condition.values)) {
|
||||
const values = condition.values.filter((value): value is string => typeof value === "string");
|
||||
return values.length > 0 && values.includes(actual);
|
||||
}
|
||||
if (Array.isArray(condition.notValues)) {
|
||||
const values = condition.notValues.filter((value): value is string => typeof value === "string");
|
||||
return !values.includes(actual);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -369,111 +401,113 @@ export function SchemaConfigFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
{schema.fields.map((field) => {
|
||||
switch (field.type) {
|
||||
case "select": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<SelectField
|
||||
value={currentVal}
|
||||
options={field.options ?? []}
|
||||
{schema.fields
|
||||
.filter((field) => fieldMatchesVisibleWhen(field, readValue, schema))
|
||||
.map((field) => {
|
||||
switch (field.type) {
|
||||
case "select": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<SelectField
|
||||
value={currentVal}
|
||||
options={field.options ?? []}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
hint={field.hint}
|
||||
checked={readValue(field) === true}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
hint={field.hint}
|
||||
checked={readValue(field) === true}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftNumberInput
|
||||
value={Number(readValue(field) ?? 0)}
|
||||
onCommit={(v) => writeValue(field, v)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftNumberInput
|
||||
value={Number(readValue(field) ?? 0)}
|
||||
onCommit={(v) => writeValue(field, v)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
case "textarea":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftTextarea
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftTextarea
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
case "combobox": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
// Dynamic options: if meta.providerModels exists, compute options
|
||||
// based on the current provider value
|
||||
let comboboxOptions = field.options ?? [];
|
||||
if (field.meta?.providerModels) {
|
||||
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
|
||||
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
|
||||
if (providerVal === "auto") {
|
||||
// Auto: show all models from all providers, grouped by provider
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const providerOptions = providerLabel?.options ?? [];
|
||||
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
|
||||
models.map((m) => ({
|
||||
case "combobox": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
// Dynamic options: if meta.providerModels exists, compute options
|
||||
// based on the current provider value
|
||||
let comboboxOptions = field.options ?? [];
|
||||
if (field.meta?.providerModels) {
|
||||
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
|
||||
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
|
||||
if (providerVal === "auto") {
|
||||
// Auto: show all models from all providers, grouped by provider
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const providerOptions = providerLabel?.options ?? [];
|
||||
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
|
||||
models.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
const providerModels = modelsByProvider[providerVal] ?? [];
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
|
||||
comboboxOptions = providerModels.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
const providerModels = modelsByProvider[providerVal] ?? [];
|
||||
const providerLabel = schema.fields.find((f) => f.key === "provider");
|
||||
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
|
||||
comboboxOptions = providerModels.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
group: provName,
|
||||
}));
|
||||
group: provName,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<ComboboxField
|
||||
value={currentVal}
|
||||
options={comboboxOptions}
|
||||
onChange={(v) => writeValue(field, v || undefined)}
|
||||
placeholder={field.hint}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<ComboboxField
|
||||
value={currentVal}
|
||||
options={comboboxOptions}
|
||||
onChange={(v) => writeValue(field, v || undefined)}
|
||||
placeholder={field.hint}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftInput
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
})}
|
||||
case "text":
|
||||
default:
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<DraftInput
|
||||
value={String(readValue(field) ?? "")}
|
||||
onCommit={(v) => writeValue(field, v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+25
-12
@@ -69,6 +69,15 @@ export interface AgentPermissionUpdate {
|
||||
canAssignTasks: boolean;
|
||||
}
|
||||
|
||||
export interface AgentWakeRequest {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
idempotencyKey?: string | null;
|
||||
forceFreshSession?: boolean;
|
||||
}
|
||||
|
||||
function withCompanyScope(path: string, companyId?: string) {
|
||||
if (!companyId) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
@@ -171,10 +180,19 @@ export const agentsApi = {
|
||||
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
||||
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
||||
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
||||
adapterModels: (companyId: string, type: string, options?: { refresh?: boolean }) =>
|
||||
api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${options?.refresh ? "?refresh=1" : ""}`,
|
||||
),
|
||||
adapterModels: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
options?: { refresh?: boolean; environmentId?: string | null },
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.refresh) params.set("refresh", "1");
|
||||
if (options?.environmentId) params.set("environmentId", options.environmentId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${query}`,
|
||||
);
|
||||
},
|
||||
detectModel: (companyId: string, type: string) =>
|
||||
api.get<DetectedAdapterModel | null>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
||||
@@ -195,16 +213,11 @@ export const agentsApi = {
|
||||
`/companies/${companyId}/adapters/${type}/test-environment`,
|
||||
data,
|
||||
),
|
||||
invoke: (id: string, companyId?: string) => api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), {}),
|
||||
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
|
||||
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
|
||||
wakeup: (
|
||||
id: string,
|
||||
data: {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
idempotencyKey?: string | null;
|
||||
},
|
||||
data: AgentWakeRequest,
|
||||
companyId?: string,
|
||||
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
||||
loginWithClaude: (id: string, companyId?: string) =>
|
||||
|
||||
+10
-1
@@ -12,6 +12,7 @@ import type {
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
IssueRetryNowResponse,
|
||||
IssueThreadInteraction,
|
||||
IssueTreeControlPreview,
|
||||
IssueTreeHold,
|
||||
@@ -43,6 +44,7 @@ export const issuesApi = {
|
||||
workspaceId?: string;
|
||||
executionWorkspaceId?: string;
|
||||
originKind?: string;
|
||||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
descendantOf?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
@@ -66,6 +68,7 @@ export const issuesApi = {
|
||||
if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId);
|
||||
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
|
||||
if (filters?.originKind) params.set("originKind", filters.originKind);
|
||||
if (filters?.originKindPrefix) params.set("originKindPrefix", filters.originKindPrefix);
|
||||
if (filters?.originId) params.set("originId", filters.originId);
|
||||
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
@@ -126,6 +129,9 @@ export const issuesApi = {
|
||||
}>(`/issues/${id}/tree-control/state`),
|
||||
releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) =>
|
||||
api.post<IssueTreeHold>(`/issues/${id}/tree-holds/${holdId}/release`, data),
|
||||
checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}),
|
||||
retryScheduledRetryNow: (id: string) =>
|
||||
api.post<IssueRetryNowResponse>(`/issues/${id}/scheduled-retry/retry-now`, {}),
|
||||
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
||||
checkout: (id: string, agentId: string) =>
|
||||
api.post<Issue>(`/issues/${id}/checkout`, {
|
||||
@@ -171,7 +177,10 @@ export const issuesApi = {
|
||||
getComment: (id: string, commentId: string) =>
|
||||
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||
getCostSummary: (id: string) => api.get<IssueCostSummary>(`/issues/${id}/cost-summary`),
|
||||
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
|
||||
const qs = options.excludeRoot ? "?excludeRoot=true" : "";
|
||||
return api.get<IssueCostSummary>(`/issues/${id}/cost-summary${qs}`);
|
||||
},
|
||||
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters ?? {})) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
api: mockApi,
|
||||
}));
|
||||
|
||||
import { pluginsApi } from "./plugins";
|
||||
|
||||
describe("pluginsApi local folders", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockApi.post.mockReset();
|
||||
mockApi.put.mockReset();
|
||||
mockApi.get.mockResolvedValue({});
|
||||
mockApi.post.mockResolvedValue({});
|
||||
mockApi.put.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("lists company-scoped local folders for a plugin", async () => {
|
||||
await pluginsApi.listLocalFolders("plugin-1", "company-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/plugins/plugin-1/companies/company-1/local-folders",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates a candidate folder path without saving", async () => {
|
||||
await pluginsApi.validateLocalFolder("plugin-1", "company-1", "wiki-root", {
|
||||
path: "/tmp/wiki",
|
||||
access: "readWrite",
|
||||
requiredFiles: ["WIKI.md"],
|
||||
});
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith(
|
||||
"/plugins/plugin-1/companies/company-1/local-folders/wiki-root/validate",
|
||||
{
|
||||
path: "/tmp/wiki",
|
||||
access: "readWrite",
|
||||
requiredFiles: ["WIKI.md"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("saves through the local-folder PUT endpoint", async () => {
|
||||
await pluginsApi.configureLocalFolder("plugin-1", "company-1", "wiki-root", {
|
||||
path: "/tmp/wiki",
|
||||
requiredDirectories: ["wiki"],
|
||||
});
|
||||
|
||||
expect(mockApi.put).toHaveBeenCalledWith(
|
||||
"/plugins/plugin-1/companies/company-1/local-folders/wiki-root",
|
||||
{
|
||||
path: "/tmp/wiki",
|
||||
requiredDirectories: ["wiki"],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
PluginLauncherDeclaration,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginRecord,
|
||||
PluginConfig,
|
||||
PluginStatus,
|
||||
@@ -140,6 +141,54 @@ export interface AvailablePluginExample {
|
||||
tag: "example";
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderProblem {
|
||||
code:
|
||||
| "not_configured"
|
||||
| "not_absolute"
|
||||
| "missing"
|
||||
| "not_directory"
|
||||
| "not_readable"
|
||||
| "not_writable"
|
||||
| "missing_directory"
|
||||
| "missing_file"
|
||||
| "path_traversal"
|
||||
| "symlink_escape"
|
||||
| "atomic_write_failed";
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderStatus {
|
||||
folderKey: string;
|
||||
configured: boolean;
|
||||
path: string | null;
|
||||
realPath: string | null;
|
||||
access: "read" | "readWrite";
|
||||
readable: boolean;
|
||||
writable: boolean;
|
||||
requiredDirectories: string[];
|
||||
requiredFiles: string[];
|
||||
missingDirectories: string[];
|
||||
missingFiles: string[];
|
||||
healthy: boolean;
|
||||
problems: PluginLocalFolderProblem[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PluginLocalFoldersResponse {
|
||||
pluginId: string;
|
||||
companyId: string;
|
||||
declarations: PluginLocalFolderDeclaration[];
|
||||
folders: PluginLocalFolderStatus[];
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderSaveInput {
|
||||
path: string;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin management API client.
|
||||
*
|
||||
@@ -337,6 +386,48 @@ export const pluginsApi = {
|
||||
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
|
||||
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
|
||||
|
||||
/**
|
||||
* List manifest-declared and stored company-scoped local folders for a plugin.
|
||||
*/
|
||||
listLocalFolders: (pluginId: string, companyId: string) =>
|
||||
api.get<PluginLocalFoldersResponse>(`/plugins/${pluginId}/companies/${companyId}/local-folders`),
|
||||
|
||||
/**
|
||||
* Inspect a configured local folder without changing persisted settings.
|
||||
*/
|
||||
localFolderStatus: (pluginId: string, companyId: string, folderKey: string) =>
|
||||
api.get<PluginLocalFolderStatus>(
|
||||
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/status`,
|
||||
),
|
||||
|
||||
/**
|
||||
* Validate a candidate local folder path without saving it.
|
||||
*/
|
||||
validateLocalFolder: (
|
||||
pluginId: string,
|
||||
companyId: string,
|
||||
folderKey: string,
|
||||
input: PluginLocalFolderSaveInput,
|
||||
) =>
|
||||
api.post<PluginLocalFolderStatus>(
|
||||
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/validate`,
|
||||
input,
|
||||
),
|
||||
|
||||
/**
|
||||
* Persist a company-scoped local folder path and return its inspected status.
|
||||
*/
|
||||
configureLocalFolder: (
|
||||
pluginId: string,
|
||||
companyId: string,
|
||||
folderKey: string,
|
||||
input: PluginLocalFolderSaveInput,
|
||||
) =>
|
||||
api.put<PluginLocalFolderStatus>(
|
||||
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}`,
|
||||
input,
|
||||
),
|
||||
|
||||
// ===========================================================================
|
||||
// Bridge proxy endpoints — used by the plugin UI bridge runtime
|
||||
// ===========================================================================
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Routine,
|
||||
RoutineDetail,
|
||||
RoutineListItem,
|
||||
RoutineRevision,
|
||||
RoutineRun,
|
||||
RoutineRunSummary,
|
||||
RoutineTrigger,
|
||||
@@ -21,6 +22,18 @@ export interface RotateRoutineTriggerResponse {
|
||||
secretMaterial: RoutineTriggerSecretMaterial;
|
||||
}
|
||||
|
||||
export interface RestoreRoutineRevisionSecretMaterial extends RoutineTriggerSecretMaterial {
|
||||
triggerId: string;
|
||||
}
|
||||
|
||||
export interface RestoreRoutineRevisionResponse {
|
||||
routine: Routine;
|
||||
revision: RoutineRevision;
|
||||
restoredFromRevisionId: string;
|
||||
restoredFromRevisionNumber: number;
|
||||
secretMaterials: RestoreRoutineRevisionSecretMaterial[];
|
||||
}
|
||||
|
||||
export const routinesApi = {
|
||||
list: (companyId: string, filters?: { projectId?: string | null }) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -32,6 +45,16 @@ export const routinesApi = {
|
||||
api.post<Routine>(`/companies/${companyId}/routines`, data),
|
||||
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Routine>(`/routines/${id}`, data),
|
||||
listRevisions: (id: string) => api.get<RoutineRevision[]>(`/routines/${id}/revisions`),
|
||||
restoreRevision: (
|
||||
id: string,
|
||||
revisionId: string,
|
||||
body: { changeSummary?: string | null } = {},
|
||||
) =>
|
||||
api.post<RestoreRoutineRevisionResponse>(
|
||||
`/routines/${id}/revisions/${revisionId}/restore`,
|
||||
body,
|
||||
),
|
||||
listRuns: (id: string, limit: number = 50) => api.get<RoutineRunSummary[]>(`/routines/${id}/runs?limit=${limit}`),
|
||||
createTrigger: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<RoutineTriggerResponse>(`/routines/${id}/triggers`, data),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CompanySearchResponse, CompanySearchScope } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CompanySearchParams {
|
||||
q: string;
|
||||
scope?: CompanySearchScope;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const searchApi = {
|
||||
search: (companyId: string, params: CompanySearchParams) => {
|
||||
const search = new URLSearchParams();
|
||||
search.set("q", params.q);
|
||||
if (params.scope) search.set("scope", params.scope);
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||
const qs = search.toString();
|
||||
return api.get<CompanySearchResponse>(
|
||||
`/companies/${companyId}/search${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
+129
-16
@@ -1,30 +1,143 @@
|
||||
import type { CompanySecret, SecretProviderDescriptor, SecretProvider } from "@paperclipai/shared";
|
||||
import type {
|
||||
CompanySecret,
|
||||
CompanySecretUsageBinding,
|
||||
CompanySecretProviderConfig,
|
||||
RemoteSecretImportPreviewResult,
|
||||
RemoteSecretImportResult,
|
||||
SecretAccessEvent,
|
||||
SecretManagedMode,
|
||||
SecretProvider,
|
||||
SecretProviderConfigStatus,
|
||||
SecretProviderConfigHealthResponse,
|
||||
SecretProviderDescriptor,
|
||||
SecretStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface SecretUsageResponse {
|
||||
secretId: string;
|
||||
bindings: CompanySecretUsageBinding[];
|
||||
}
|
||||
|
||||
export interface CreateSecretInput {
|
||||
name: string;
|
||||
key?: string;
|
||||
provider?: SecretProvider;
|
||||
managedMode?: SecretManagedMode;
|
||||
value?: string | null;
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
providerConfigId?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SecretProviderHealthResponse {
|
||||
providers: Array<{
|
||||
provider: SecretProvider;
|
||||
status: "ok" | "warn" | "error";
|
||||
message: string;
|
||||
warnings?: string[];
|
||||
backupGuidance?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UpdateSecretInput {
|
||||
name?: string;
|
||||
key?: string;
|
||||
status?: SecretStatus;
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface RotateSecretInput {
|
||||
value?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
providerConfigId?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateSecretProviderConfigInput {
|
||||
provider: SecretProvider;
|
||||
displayName: string;
|
||||
status?: SecretProviderConfigStatus;
|
||||
isDefault?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateSecretProviderConfigInput {
|
||||
displayName?: string;
|
||||
status?: SecretProviderConfigStatus;
|
||||
isDefault?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RemoteImportPreviewInput {
|
||||
providerConfigId: string;
|
||||
query?: string | null;
|
||||
nextToken?: string | null;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface RemoteImportSelectionInput {
|
||||
externalRef: string;
|
||||
name?: string | null;
|
||||
key?: string | null;
|
||||
description?: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface RemoteImportInput {
|
||||
providerConfigId: string;
|
||||
secrets: RemoteImportSelectionInput[];
|
||||
}
|
||||
|
||||
export const secretsApi = {
|
||||
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
||||
providers: (companyId: string) =>
|
||||
api.get<SecretProviderDescriptor[]>(`/companies/${companyId}/secret-providers`),
|
||||
create: (
|
||||
companyId: string,
|
||||
data: {
|
||||
name: string;
|
||||
value: string;
|
||||
provider?: SecretProvider;
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
},
|
||||
) => api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
|
||||
rotate: (id: string, data: { value: string; externalRef?: string | null }) =>
|
||||
providerHealth: (companyId: string) =>
|
||||
api.get<SecretProviderHealthResponse>(`/companies/${companyId}/secret-providers/health`),
|
||||
providerConfigs: (companyId: string) =>
|
||||
api.get<CompanySecretProviderConfig[]>(`/companies/${companyId}/secret-provider-configs`),
|
||||
createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) =>
|
||||
api.post<CompanySecretProviderConfig>(`/companies/${companyId}/secret-provider-configs`, data),
|
||||
updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) =>
|
||||
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, data),
|
||||
disableProviderConfig: (id: string) =>
|
||||
api.delete<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`),
|
||||
setDefaultProviderConfig: (id: string) =>
|
||||
api.post<CompanySecretProviderConfig>(`/secret-provider-configs/${id}/default`, {}),
|
||||
checkProviderConfigHealth: (id: string) =>
|
||||
api.post<SecretProviderConfigHealthResponse>(`/secret-provider-configs/${id}/health`, {}),
|
||||
create: (companyId: string, data: CreateSecretInput) =>
|
||||
api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
|
||||
update: (id: string, data: UpdateSecretInput) =>
|
||||
api.patch<CompanySecret>(`/secrets/${id}`, data),
|
||||
rotate: (id: string, data: RotateSecretInput) =>
|
||||
api.post<CompanySecret>(`/secrets/${id}/rotate`, data),
|
||||
update: (
|
||||
id: string,
|
||||
data: { name?: string; description?: string | null; externalRef?: string | null },
|
||||
) => api.patch<CompanySecret>(`/secrets/${id}`, data),
|
||||
disable: (id: string) =>
|
||||
api.patch<CompanySecret>(`/secrets/${id}`, { status: "disabled" satisfies SecretStatus }),
|
||||
enable: (id: string) =>
|
||||
api.patch<CompanySecret>(`/secrets/${id}`, { status: "active" satisfies SecretStatus }),
|
||||
archive: (id: string) =>
|
||||
api.patch<CompanySecret>(`/secrets/${id}`, { status: "archived" satisfies SecretStatus }),
|
||||
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
|
||||
usages: (id: string) =>
|
||||
api.get<{
|
||||
agents: { id: string; name: string; envKeys: string[] }[];
|
||||
skills: { id: string; name: string; slug: string }[];
|
||||
}>(`/secrets/${id}/usages`),
|
||||
usage: (id: string) => api.get<SecretUsageResponse>(`/secrets/${id}/usage`),
|
||||
accessEvents: (id: string) => api.get<SecretAccessEvent[]>(`/secrets/${id}/access-events`),
|
||||
remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) =>
|
||||
api.post<RemoteSecretImportPreviewResult>(
|
||||
`/companies/${companyId}/secrets/remote-import/preview`,
|
||||
data,
|
||||
),
|
||||
remoteImport: (companyId: string, data: RemoteImportInput) =>
|
||||
api.post<RemoteSecretImportResult>(`/companies/${companyId}/secrets/remote-import`, data),
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
@@ -55,6 +55,20 @@ async function flushReact() {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
|
||||
let lastError: unknown;
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
await flushReact();
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function createRun(index: number) {
|
||||
return {
|
||||
id: `run-${index}`,
|
||||
@@ -71,6 +85,37 @@ function createRun(index: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueRun(index: number, issueId: string) {
|
||||
return {
|
||||
...createRun(index),
|
||||
issueId,
|
||||
};
|
||||
}
|
||||
|
||||
function createIssue(id: string, identifier: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
identifier,
|
||||
title,
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
parentId: null,
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
goalId: null,
|
||||
labels: [],
|
||||
blockedByIssueIds: [],
|
||||
blocksIssueIds: [],
|
||||
createdAt: "2026-04-24T12:00:00.000Z",
|
||||
updatedAt: "2026-04-24T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("ActiveAgentsPanel", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -78,7 +123,7 @@ describe("ActiveAgentsPanel", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.get.mockRejectedValue(new Error("Issue not found"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -149,4 +194,42 @@ describe("ActiveAgentsPanel", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads exact visible run issues so task names render even when the issue list page would miss them", async () => {
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
createIssueRun(1, "65274215-0000-4000-8000-000000000000"),
|
||||
]);
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue(
|
||||
"65274215-0000-4000-8000-000000000000",
|
||||
"PAP-3562",
|
||||
"Phase 4B: Implement LLM Wiki distillation UI",
|
||||
));
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveAgentsPanel companyId="company-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await waitForMicrotaskAssertion(() => {
|
||||
expect(mockIssuesApi.get).toHaveBeenCalledWith("65274215-0000-4000-8000-000000000000");
|
||||
const issueLink = [...container.querySelectorAll("a")].find((anchor) =>
|
||||
anchor.textContent?.includes("Phase 4B"),
|
||||
);
|
||||
expect(issueLink?.textContent).toBe("PAP-3562 - Phase 4B: Implement LLM Wiki distillation UI");
|
||||
expect(issueLink?.getAttribute("href")).toBe("/issues/PAP-3562");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import type { TranscriptEntry } from "../adapters";
|
||||
@@ -56,19 +56,28 @@ export function ActiveAgentsPanel({
|
||||
const runs = liveRuns ?? [];
|
||||
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
|
||||
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
|
||||
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
|
||||
enabled: visibleRuns.length > 0,
|
||||
const visibleIssueIds = useMemo(
|
||||
() => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))],
|
||||
[visibleRuns],
|
||||
);
|
||||
|
||||
const issueQueries = useQueries({
|
||||
queries: visibleIssueIds.map((issueId) => ({
|
||||
queryKey: queryKeys.issues.detail(issueId),
|
||||
queryFn: () => issuesApi.get(issueId),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const issueById = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues ?? []) {
|
||||
map.set(issue.id, issue);
|
||||
for (const query of issueQueries) {
|
||||
const issue = query.data;
|
||||
if (issue) map.set(issue.id, issue);
|
||||
}
|
||||
return map;
|
||||
}, [issues]);
|
||||
}, [issueQueries]);
|
||||
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||
runs: visibleRuns,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "@/lib/router";
|
||||
import { Identity } from "./Identity";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { deriveInitials } from "./Identity";
|
||||
import { IssueReferenceActivitySummary } from "./IssueReferenceActivitySummary";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -52,19 +53,20 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
|
||||
|
||||
const inner = (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-3">
|
||||
<p className="flex-1 min-w-0 truncate">
|
||||
<Identity
|
||||
name={actorName}
|
||||
avatarUrl={actorAvatarUrl}
|
||||
size="xs"
|
||||
className="align-middle"
|
||||
/>
|
||||
<span className="text-muted-foreground ml-1">{verb} </span>
|
||||
{name && <span className="font-medium">{name}</span>}
|
||||
{entityTitle && <span className="text-muted-foreground ml-1">— {entityTitle}</span>}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Avatar size="xs">
|
||||
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt={actorName} />}
|
||||
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="min-w-0 flex-1 truncate">
|
||||
<span>{actorName}</span>
|
||||
<span className="text-muted-foreground"> {verb} </span>
|
||||
{name && <span className="font-medium">{name}</span>}
|
||||
{entityTitle && <span className="text-muted-foreground"> — {entityTitle}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
<IssueReferenceActivitySummary event={event} />
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
|
||||
import { extractModelName, extractProviderId } from "../lib/model-utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -56,6 +57,7 @@ import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
import { filterAcpxModelsByAgent } from "../lib/acpx-model-filter";
|
||||
|
||||
/* ---- Create mode values ---- */
|
||||
|
||||
@@ -175,6 +177,19 @@ const claudeThinkingEffortOptions = [
|
||||
{ id: "high", label: "High" },
|
||||
] as const;
|
||||
|
||||
const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2;
|
||||
const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10;
|
||||
const MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC = 1;
|
||||
const MAX_TURN_CONTINUATION_MAX_DELAY_SEC = 300;
|
||||
|
||||
function clampInteger(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
function clampDelayMsFromSeconds(value: number) {
|
||||
return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000;
|
||||
}
|
||||
|
||||
|
||||
/* ---- Form ---- */
|
||||
|
||||
@@ -309,6 +324,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
() => new Set(supportedEnvironmentDriversForAdapter(adapterType)),
|
||||
[adapterType],
|
||||
);
|
||||
const val = isCreate ? props.values : null;
|
||||
const set = isCreate
|
||||
? (patch: Partial<CreateConfigValues>) => props.onChange(patch)
|
||||
: null;
|
||||
const currentDefaultEnvironmentId = isCreate
|
||||
? val!.defaultEnvironmentId ?? ""
|
||||
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
|
||||
const currentDefaultEnvironment = useMemo(
|
||||
() => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null,
|
||||
[currentDefaultEnvironmentId, environments],
|
||||
);
|
||||
const runnableEnvironments = useMemo(
|
||||
() => environments.filter((environment) => {
|
||||
if (!supportedEnvironmentDrivers.has(environment.driver)) return false;
|
||||
@@ -321,21 +347,35 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
// Fetch adapter models for the effective adapter type
|
||||
const modelQueryKey = selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null)
|
||||
: ["agents", "none", "adapter-models", adapterType];
|
||||
const {
|
||||
data: fetchedModels,
|
||||
error: fetchedModelsError,
|
||||
} = useQuery({
|
||||
queryKey: modelQueryKey,
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType),
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
|
||||
environmentId: currentDefaultEnvironmentId || null,
|
||||
}),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
|
||||
const [refreshingModels, setRefreshingModels] = useState(false);
|
||||
const models = fetchedModels ?? externalModels ?? [];
|
||||
const rawModels = fetchedModels ?? externalModels ?? [];
|
||||
const adapterCommandField =
|
||||
adapterType === "hermes_local" ? "hermesCommand" : "command";
|
||||
const acpxAgent =
|
||||
adapterType === "acpx_local"
|
||||
? isCreate
|
||||
? String(val!.adapterSchemaValues?.agent ?? "claude")
|
||||
: eff("adapterConfig", "agent", String(config.agent ?? "claude"))
|
||||
: "";
|
||||
const models = useMemo(
|
||||
() => adapterType === "acpx_local"
|
||||
? filterAcpxModelsByAgent(rawModels, acpxAgent)
|
||||
: rawModels,
|
||||
[adapterType, rawModels, acpxAgent],
|
||||
);
|
||||
const {
|
||||
data: detectedModelData,
|
||||
refetch: refetchDetectedModel,
|
||||
@@ -349,7 +389,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
return agentsApi.detectModel(selectedCompanyId, adapterType);
|
||||
},
|
||||
enabled: Boolean(selectedCompanyId && isLocal),
|
||||
enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local"),
|
||||
});
|
||||
const detectedModel = detectedModelData?.model ?? null;
|
||||
const detectedModelCandidates = detectedModelData?.candidates ?? [];
|
||||
@@ -402,12 +442,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}, [adapterCheapDefault]);
|
||||
|
||||
// Create mode helpers
|
||||
const val = isCreate ? props.values : null;
|
||||
const set = isCreate
|
||||
? (patch: Partial<CreateConfigValues>) => props.onChange(patch)
|
||||
: null;
|
||||
|
||||
function buildAdapterConfigForTest(): Record<string, unknown> {
|
||||
if (isCreate) {
|
||||
return uiAdapter.buildAdapterConfig(val!);
|
||||
@@ -433,15 +467,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
if (!selectedCompanyId) {
|
||||
throw new Error("Select a company to test adapter environment");
|
||||
}
|
||||
const selectedEnvironmentId = isCreate
|
||||
? val!.defaultEnvironmentId ?? null
|
||||
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? null);
|
||||
return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
|
||||
adapterConfig: buildAdapterConfigForTest(),
|
||||
environmentId:
|
||||
typeof selectedEnvironmentId === "string" && selectedEnvironmentId.length > 0
|
||||
? selectedEnvironmentId
|
||||
: null,
|
||||
environmentId: currentDefaultEnvironmentId || null,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -512,19 +540,23 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const thinkingEffortKey =
|
||||
adapterType === "codex_local"
|
||||
? "modelReasoningEffort"
|
||||
: adapterType === "cursor"
|
||||
? "mode"
|
||||
: adapterType === "opencode_local"
|
||||
? "variant"
|
||||
: "effort";
|
||||
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||
? "modelReasoningEffort"
|
||||
: adapterType === "cursor"
|
||||
? "mode"
|
||||
: adapterType === "opencode_local"
|
||||
? "variant"
|
||||
: "effort";
|
||||
const thinkingEffortOptions =
|
||||
adapterType === "codex_local"
|
||||
? codexThinkingEffortOptions
|
||||
: adapterType === "cursor"
|
||||
? cursorModeOptions
|
||||
: adapterType === "opencode_local"
|
||||
? openCodeThinkingEffortOptions
|
||||
: claudeThinkingEffortOptions;
|
||||
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||
? codexThinkingEffortOptions
|
||||
: adapterType === "cursor"
|
||||
? cursorModeOptions
|
||||
: adapterType === "opencode_local"
|
||||
? openCodeThinkingEffortOptions
|
||||
: claudeThinkingEffortOptions;
|
||||
const currentThinkingEffort = isCreate
|
||||
? val!.thinkingEffort
|
||||
: adapterType === "codex_local"
|
||||
@@ -533,12 +565,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
"modelReasoningEffort",
|
||||
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
||||
)
|
||||
: adapterType === "cursor"
|
||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||
const showThinkingEffort = adapterType !== "gemini_local";
|
||||
: adapterType === "acpx_local" && acpxAgent === "codex"
|
||||
? eff(
|
||||
"adapterConfig",
|
||||
"modelReasoningEffort",
|
||||
String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? ""),
|
||||
)
|
||||
: adapterType === "cursor"
|
||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||
const showThinkingEffort = adapterType !== "gemini_local" && adapterType !== "cursor_cloud";
|
||||
const codexSearchEnabled = adapterType === "codex_local"
|
||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||
: false;
|
||||
@@ -625,9 +663,27 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
heartbeat: mergedHeartbeat,
|
||||
};
|
||||
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
||||
const currentDefaultEnvironmentId = isCreate
|
||||
? val!.defaultEnvironmentId ?? ""
|
||||
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
|
||||
const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat);
|
||||
const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation);
|
||||
const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true);
|
||||
const maxTurnContinuationMaxAttempts = clampInteger(
|
||||
asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS),
|
||||
0,
|
||||
MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP,
|
||||
);
|
||||
const maxTurnContinuationDelaySec = clampInteger(
|
||||
asFiniteNumber(maxTurnContinuation.delayMs, MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC * 1000) / 1000,
|
||||
0,
|
||||
MAX_TURN_CONTINUATION_MAX_DELAY_SEC,
|
||||
);
|
||||
|
||||
function updateMaxTurnContinuation(patch: Record<string, unknown>) {
|
||||
mark("heartbeat", "maxTurnContinuation", {
|
||||
...maxTurnContinuation,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||
@@ -800,7 +856,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
} else if (t === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (t === "opencode_local") {
|
||||
nextValues.model = "";
|
||||
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
}
|
||||
set!(nextValues);
|
||||
} else {
|
||||
@@ -816,9 +872,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? DEFAULT_CODEX_LOCAL_MODEL
|
||||
: t === "gemini_local"
|
||||
? DEFAULT_GEMINI_LOCAL_MODEL
|
||||
: t === "opencode_local"
|
||||
? DEFAULT_OPENCODE_LOCAL_MODEL
|
||||
: t === "cursor"
|
||||
? DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: "",
|
||||
: "",
|
||||
effort: "",
|
||||
modelReasoningEffort: "",
|
||||
variant: "",
|
||||
@@ -941,11 +999,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
creatable
|
||||
detectedModel={detectedModel}
|
||||
detectedModelCandidates={[]}
|
||||
onDetectModel={async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}}
|
||||
onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined}
|
||||
onDetectModel={adapterType === "opencode_local"
|
||||
? undefined
|
||||
: async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}}
|
||||
onRefreshModels={
|
||||
adapterType === "codex_local" || adapterType === "acpx_local"
|
||||
? handleRefreshModels
|
||||
: undefined
|
||||
}
|
||||
refreshingModels={refreshingModels}
|
||||
detectModelLabel="Detect model"
|
||||
emptyDetectHint="No model detected. Select or enter one manually."
|
||||
@@ -958,6 +1022,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
: "Failed to load adapter models.")}
|
||||
</p>
|
||||
)}
|
||||
{adapterType === "opencode_local"
|
||||
&& currentDefaultEnvironment
|
||||
&& currentDefaultEnvironment.driver !== "local" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Live OpenCode model discovery only runs for Local environments. Using the curated list and manual entry for {currentDefaultEnvironment.name}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{supportsModelProfiles && (
|
||||
<CheapModelSection
|
||||
@@ -1182,6 +1253,40 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-border/70 px-3 py-2">
|
||||
<ToggleField
|
||||
label="Continue after max-turn stop"
|
||||
hint={help.maxTurnContinuationEnabled}
|
||||
checked={maxTurnContinuationEnabled}
|
||||
onChange={(v) => updateMaxTurnContinuation({ enabled: v })}
|
||||
/>
|
||||
{maxTurnContinuationEnabled ? (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Continuation attempts" hint={help.maxTurnContinuationMaxAttempts}>
|
||||
<DraftNumberInput
|
||||
value={maxTurnContinuationMaxAttempts}
|
||||
onCommit={(v) =>
|
||||
updateMaxTurnContinuation({
|
||||
maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP),
|
||||
})}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Continuation delay (sec)" hint={help.maxTurnContinuationDelaySec}>
|
||||
<DraftNumberInput
|
||||
value={maxTurnContinuationDelaySec}
|
||||
onCommit={(v) =>
|
||||
updateMaxTurnContinuation({
|
||||
delayMs: clampDelayMsFromSeconds(v),
|
||||
})}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { KeyboardEventHandler, ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -46,8 +46,12 @@ vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => sidebarState,
|
||||
}));
|
||||
|
||||
const navigateState = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useNavigate: () => navigateState.navigate,
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
@@ -73,15 +77,18 @@ vi.mock("@/components/ui/command", () => ({
|
||||
CommandInput: ({
|
||||
value,
|
||||
onValueChange,
|
||||
onKeyDown,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||
}) => (
|
||||
<div>
|
||||
<input
|
||||
aria-label="Command search"
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button type="button" aria-label="Set query" onClick={() => onValueChange("pull/3303")} />
|
||||
</div>
|
||||
@@ -89,10 +96,16 @@ vi.mock("@/components/ui/command", () => ({
|
||||
CommandItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
"data-testid": testId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSelect?: () => void;
|
||||
}) => <button onClick={onSelect}>{children}</button>,
|
||||
"data-testid"?: string;
|
||||
}) => (
|
||||
<button data-testid={testId} onClick={onSelect}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
CommandList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandSeparator: () => <hr />,
|
||||
}));
|
||||
@@ -153,6 +166,7 @@ describe("CommandPalette", () => {
|
||||
mockIssuesApi.list.mockReset();
|
||||
mockAgentsApi.list.mockReset();
|
||||
mockProjectsApi.list.mockReset();
|
||||
navigateState.navigate.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
@@ -188,4 +202,78 @@ describe("CommandPalette", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a Search-all command when the query is non-empty and routes Enter to /search when no issues match", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
const input = container.querySelector('input[aria-label="Command search"]') as HTMLInputElement;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
nativeSetter.call(input, "auth flake");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const searchAllButton = container.querySelector(
|
||||
'button[data-testid="command-search-all"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(searchAllButton).not.toBeNull();
|
||||
expect(searchAllButton!.textContent).toContain("auth flake");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=auth%20flake");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to /search when the user clicks the Search-all command", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true }));
|
||||
});
|
||||
|
||||
const input = container.querySelector('input[aria-label="Command search"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
nativeSetter.call(input, "deflake");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
let searchAllButton: HTMLButtonElement | null = null;
|
||||
await waitForAssertion(() => {
|
||||
searchAllButton = container.querySelector(
|
||||
'button[data-testid="command-search-all"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(searchAllButton).not.toBeNull();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
searchAllButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=deflake");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,10 +28,18 @@ import {
|
||||
History,
|
||||
SquarePen,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { agentUrl, projectUrl } from "../lib/utils";
|
||||
|
||||
const SEARCH_ALL_VALUE = "__paperclip-search-all__";
|
||||
|
||||
export function buildFullSearchPath(query: string) {
|
||||
const trimmed = query.trim();
|
||||
return trimmed.length === 0 ? "/search" : `/search?q=${encodeURIComponent(trimmed)}`;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -90,6 +98,10 @@ export function CommandPalette() {
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
function goFullSearch() {
|
||||
go(buildFullSearchPath(searchQuery));
|
||||
}
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
@@ -100,6 +112,9 @@ export function CommandPalette() {
|
||||
[issues, searchedIssues, searchQuery],
|
||||
);
|
||||
|
||||
const showSearchAll = searchQuery.length > 0;
|
||||
const showEmptyHint = showSearchAll && visibleIssues.length === 0;
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={(v) => {
|
||||
setOpen(v);
|
||||
@@ -109,9 +124,47 @@ export function CommandPalette() {
|
||||
placeholder="Search issues, agents, projects..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && showEmptyHint) {
|
||||
event.preventDefault();
|
||||
goFullSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{showSearchAll ? (
|
||||
<span>
|
||||
No quick issue matches. Press{" "}
|
||||
<kbd className="rounded border border-border bg-muted px-1 py-0.5 text-[10px]">↵</kbd>{" "}
|
||||
to <span className="font-medium">search all</span> or keep typing to refine.
|
||||
</span>
|
||||
) : (
|
||||
"No results found."
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{showSearchAll ? (
|
||||
<CommandGroup heading="Search">
|
||||
<CommandItem
|
||||
value={`${SEARCH_ALL_VALUE} ${searchQuery}`}
|
||||
onSelect={goFullSearch}
|
||||
className="bg-accent/40 border border-accent data-[selected=true]:bg-accent/60"
|
||||
data-testid="command-search-all"
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
<span className="flex-1 truncate">
|
||||
Search all for <span className="font-semibold">“{searchQuery}”</span>
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>open full search</span>
|
||||
<kbd className="rounded border border-border bg-background px-1 py-0.5 text-[10px]">↵</kbd>
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
|
||||
{showSearchAll ? <CommandSeparator /> : null}
|
||||
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem
|
||||
|
||||
@@ -192,6 +192,9 @@ describe("CommentThread", () => {
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "Please continue validation.",
|
||||
authorType: "user",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
followUpRequested: true,
|
||||
createdAt: new Date("2026-03-11T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
|
||||
@@ -349,6 +352,9 @@ describe("CommentThread", () => {
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "Hello from the comment body",
|
||||
authorType: "user",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||
}]}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
import { ApprovalCard } from "./ApprovalCard";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { formatTimelineWorkspaceLabel, type IssueTimelineAssignee, type IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn, formatDateTime } from "../lib/utils";
|
||||
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||||
@@ -535,6 +535,21 @@ function TimelineEventCard({
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{event.workspaceChange ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="w-14 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Workspace
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimelineWorkspaceLabel(event.workspaceChange.from)}
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">
|
||||
{formatTimelineWorkspaceLabel(event.workspaceChange.to)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Paperclip, Plus } from "lucide-react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
MouseSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompanyOrder } from "../hooks/useCompanyOrder";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isSelected,
|
||||
hasLiveAgents,
|
||||
hasUnreadInbox,
|
||||
onSelect,
|
||||
}: {
|
||||
company: Company;
|
||||
isSelected: boolean;
|
||||
hasLiveAgents: boolean;
|
||||
hasUnreadInbox: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: company.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="overflow-visible">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`/${company.issuePrefix}/dashboard`}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
className="relative flex items-center justify-center group overflow-visible"
|
||||
>
|
||||
{/* Selection indicator pill */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-[height] duration-150",
|
||||
isSelected
|
||||
? "h-5"
|
||||
: "h-0 group-hover:h-2"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn("relative overflow-visible transition-transform duration-150", isDragging && "scale-105")}
|
||||
>
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
brandColor={company.brandColor}
|
||||
className={cn(
|
||||
isSelected
|
||||
? "rounded-[14px]"
|
||||
: "rounded-[22px] group-hover:rounded-[14px]",
|
||||
isDragging && "shadow-lg",
|
||||
)}
|
||||
/>
|
||||
{hasLiveAgents && (
|
||||
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-80" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{hasUnreadInbox && (
|
||||
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{company.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyRail() {
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isInstanceRoute = location.pathname.startsWith("/instance/");
|
||||
const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId;
|
||||
const sidebarCompanies = useMemo(
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
|
||||
|
||||
const liveRunsQueries = useQueries({
|
||||
queries: companyIds.map((companyId) => ({
|
||||
queryKey: queryKeys.liveRuns(companyId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||
refetchInterval: 10_000,
|
||||
})),
|
||||
});
|
||||
const sidebarBadgeQueries = useQueries({
|
||||
queries: companyIds.map((companyId) => ({
|
||||
queryKey: queryKeys.sidebarBadges(companyId),
|
||||
queryFn: () => sidebarBadgesApi.get(companyId),
|
||||
refetchInterval: 15_000,
|
||||
})),
|
||||
});
|
||||
const hasLiveAgentsByCompanyId = useMemo(() => {
|
||||
const result = new Map<string, boolean>();
|
||||
companyIds.forEach((companyId, index) => {
|
||||
result.set(companyId, (liveRunsQueries[index]?.data?.length ?? 0) > 0);
|
||||
});
|
||||
return result;
|
||||
}, [companyIds, liveRunsQueries]);
|
||||
const hasUnreadInboxByCompanyId = useMemo(() => {
|
||||
const result = new Map<string, boolean>();
|
||||
companyIds.forEach((companyId, index) => {
|
||||
result.set(companyId, (sidebarBadgeQueries[index]?.data?.inbox ?? 0) > 0);
|
||||
});
|
||||
return result;
|
||||
}, [companyIds, sidebarBadgeQueries]);
|
||||
|
||||
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
||||
companies: sidebarCompanies,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const ids = orderedCompanies.map((c) => c.id);
|
||||
const oldIndex = ids.indexOf(active.id as string);
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||
},
|
||||
[orderedCompanies, persistOrder]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-[72px] shrink-0 h-full bg-background border-r border-border">
|
||||
{/* Paperclip icon - aligned with top sections (implied line, no visible border) */}
|
||||
<div className="flex items-center justify-center h-12 w-full shrink-0">
|
||||
<Paperclip className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Company list */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2 py-3 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((c) => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isSelected={company.id === highlightedCompanyId}
|
||||
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
|
||||
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
|
||||
onSelect={() => {
|
||||
setSelectedCompanyId(company.id);
|
||||
if (isInstanceRoute) {
|
||||
navigate(`/${company.issuePrefix}/dashboard`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Separator before add button */}
|
||||
<div className="w-8 h-px bg-border mx-auto shrink-0" />
|
||||
|
||||
{/* Add company button */}
|
||||
<div className="flex items-center justify-center py-2 shrink-0">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => openOnboarding()}
|
||||
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-[border-color,color,border-radius] duration-150"
|
||||
aria-label="Add company"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>Add company</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,10 @@ vi.mock("./SidebarNavItem", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarCompanyMenu", () => ({
|
||||
SidebarCompanyMenu: () => <div>Workspace switcher</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sidebarBadges", () => ({
|
||||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
@@ -108,6 +112,7 @@ describe("CompanySettingsSidebar", () => {
|
||||
expect(container.textContent).toContain("Environments");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings",
|
||||
@@ -137,6 +142,13 @@ describe("CompanySettingsSidebar", () => {
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/secrets",
|
||||
label: "Secrets",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CompanySettingsSidebar() {
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
@@ -69,6 +69,7 @@ export function CompanySettingsSidebar() {
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import type { DocumentRevision } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildLineDiff, type DiffRow } from "../lib/line-diff";
|
||||
import { relativeTime } from "../lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -27,96 +28,6 @@ function getRevisionLabel(revision: DocumentRevision) {
|
||||
return `rev ${revision.revisionNumber} — ${relativeTime(revision.createdAt)} • ${actor}`;
|
||||
}
|
||||
|
||||
type DiffRow = {
|
||||
kind: "context" | "removed" | "added";
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function buildLineDiff(oldText: string, newText: string): DiffRow[] {
|
||||
const oldLines = oldText.split("\n");
|
||||
const newLines = newText.split("\n");
|
||||
const oldCount = oldLines.length;
|
||||
const newCount = newLines.length;
|
||||
const dp = Array.from({ length: oldCount + 1 }, () => Array<number>(newCount + 1).fill(0));
|
||||
|
||||
for (let i = oldCount - 1; i >= 0; i -= 1) {
|
||||
for (let j = newCount - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] = oldLines[i] === newLines[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: DiffRow[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
let oldLineNumber = 1;
|
||||
let newLineNumber = 1;
|
||||
|
||||
while (i < oldCount && j < newCount) {
|
||||
if (oldLines[i] === newLines[j]) {
|
||||
rows.push({
|
||||
kind: "context",
|
||||
oldLineNumber,
|
||||
newLineNumber,
|
||||
text: oldLines[i],
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
oldLineNumber += 1;
|
||||
newLineNumber += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
rows.push({
|
||||
kind: "removed",
|
||||
oldLineNumber,
|
||||
newLineNumber: null,
|
||||
text: oldLines[i],
|
||||
});
|
||||
i += 1;
|
||||
oldLineNumber += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: "added",
|
||||
oldLineNumber: null,
|
||||
newLineNumber,
|
||||
text: newLines[j],
|
||||
});
|
||||
j += 1;
|
||||
newLineNumber += 1;
|
||||
}
|
||||
|
||||
while (i < oldCount) {
|
||||
rows.push({
|
||||
kind: "removed",
|
||||
oldLineNumber,
|
||||
newLineNumber: null,
|
||||
text: oldLines[i],
|
||||
});
|
||||
i += 1;
|
||||
oldLineNumber += 1;
|
||||
}
|
||||
|
||||
while (j < newCount) {
|
||||
rows.push({
|
||||
kind: "added",
|
||||
oldLineNumber: null,
|
||||
newLineNumber,
|
||||
text: newLines[j],
|
||||
});
|
||||
j += 1;
|
||||
newLineNumber += 1;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function DocumentDiffModal({
|
||||
issueId,
|
||||
documentKey,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
|
||||
import { X } from "lucide-react";
|
||||
import type { CompanySecret, EnvBinding, SecretVersionSelector } from "@paperclipai/shared";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
@@ -12,15 +12,20 @@ type Row = {
|
||||
source: "plain" | "secret";
|
||||
plainValue: string;
|
||||
secretId: string;
|
||||
version: SecretVersionSelector;
|
||||
};
|
||||
|
||||
function emptyRow(): Row {
|
||||
return { key: "", source: "plain", plainValue: "", secretId: "", version: "latest" };
|
||||
}
|
||||
|
||||
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||
if (!rec || typeof rec !== "object") {
|
||||
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
|
||||
return [emptyRow()];
|
||||
}
|
||||
const entries = Object.entries(rec).map(([key, binding]) => {
|
||||
if (typeof binding === "string") {
|
||||
return { key, source: "plain" as const, plainValue: binding, secretId: "" };
|
||||
return { key, source: "plain" as const, plainValue: binding, secretId: "", version: "latest" as const };
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
@@ -28,12 +33,16 @@ function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "secret_ref"
|
||||
) {
|
||||
const record = binding as { secretId?: unknown };
|
||||
const record = binding as { secretId?: unknown; version?: unknown };
|
||||
const version: SecretVersionSelector = typeof record.version === "number"
|
||||
? record.version
|
||||
: "latest";
|
||||
return {
|
||||
key,
|
||||
source: "secret" as const,
|
||||
plainValue: "",
|
||||
secretId: typeof record.secretId === "string" ? record.secretId : "",
|
||||
version,
|
||||
};
|
||||
}
|
||||
if (
|
||||
@@ -48,11 +57,12 @@ function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||
source: "plain" as const,
|
||||
plainValue: typeof record.value === "string" ? record.value : "",
|
||||
secretId: "",
|
||||
version: "latest" as const,
|
||||
};
|
||||
}
|
||||
return { key, source: "plain" as const, plainValue: "", secretId: "" };
|
||||
return { key, source: "plain" as const, plainValue: "", secretId: "", version: "latest" as const };
|
||||
});
|
||||
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
|
||||
return [...entries, emptyRow()];
|
||||
}
|
||||
|
||||
export function EnvVarEditor({
|
||||
@@ -90,7 +100,7 @@ export function EnvVarEditor({
|
||||
if (!key) continue;
|
||||
if (row.source === "secret") {
|
||||
if (row.secretId) {
|
||||
rec[key] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
|
||||
rec[key] = { type: "secret_ref", secretId: row.secretId, version: row.version };
|
||||
} else {
|
||||
rec[key] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
@@ -103,13 +113,15 @@ export function EnvVarEditor({
|
||||
}
|
||||
|
||||
function updateRow(index: number, patch: Partial<Row>) {
|
||||
const withPatch = rows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row));
|
||||
const withPatch: Row[] = rows.map((row, rowIndex) =>
|
||||
rowIndex === index ? { ...row, ...patch, version: patch.version ?? row.version } : row,
|
||||
);
|
||||
if (
|
||||
withPatch[withPatch.length - 1].key ||
|
||||
withPatch[withPatch.length - 1].plainValue ||
|
||||
withPatch[withPatch.length - 1].secretId
|
||||
) {
|
||||
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
||||
withPatch.push(emptyRow());
|
||||
}
|
||||
setRows(withPatch);
|
||||
emit(withPatch);
|
||||
@@ -123,7 +135,7 @@ export function EnvVarEditor({
|
||||
next[next.length - 1].plainValue ||
|
||||
next[next.length - 1].secretId
|
||||
) {
|
||||
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
||||
next.push(emptyRow());
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
@@ -190,17 +202,46 @@ export function EnvVarEditor({
|
||||
{row.source === "secret" ? (
|
||||
<>
|
||||
<select
|
||||
className={cn(inputClass, "flex-[3] bg-background")}
|
||||
className={cn(inputClass, "flex-[3] bg-background", row.secretId && !secrets.some((s) => s.id === row.secretId) && "border-destructive text-destructive")}
|
||||
value={row.secretId}
|
||||
onChange={(event) => updateRow(index, { secretId: event.target.value })}
|
||||
>
|
||||
<option value="">Select secret...</option>
|
||||
{row.secretId && !secrets.some((s) => s.id === row.secretId) ? (
|
||||
<option value={row.secretId}>Missing ({row.secretId.slice(0, 8)}…)</option>
|
||||
) : null}
|
||||
{secrets.map((secret) => (
|
||||
<option key={secret.id} value={secret.id}>
|
||||
{secret.name}
|
||||
{secret.status !== "active" ? ` (${secret.status})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={cn(inputClass, "flex-[1] bg-background")}
|
||||
value={row.version === "latest" ? "latest" : String(row.version)}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
updateRow(index, { version: raw === "latest" ? "latest" : Number.parseInt(raw, 10) });
|
||||
}}
|
||||
disabled={!row.secretId}
|
||||
aria-label="Version"
|
||||
>
|
||||
<option value="latest">latest</option>
|
||||
{(() => {
|
||||
const selected = secrets.find((s) => s.id === row.secretId);
|
||||
if (!selected) return null;
|
||||
return Array.from({ length: Math.max(0, selected.latestVersion) }, (_, idx) => {
|
||||
const version = selected.latestVersion - idx;
|
||||
if (version <= 0) return null;
|
||||
return (
|
||||
<option key={version} value={version}>
|
||||
v{version}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||
@@ -251,8 +292,38 @@ export function EnvVarEditor({
|
||||
);
|
||||
})}
|
||||
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
|
||||
{(() => {
|
||||
const issues: { key: string; reason: string }[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.source !== "secret" || !row.secretId) continue;
|
||||
const secret = secrets.find((s) => s.id === row.secretId);
|
||||
if (!secret) {
|
||||
issues.push({ key: row.key.trim() || row.secretId, reason: "missing" });
|
||||
} else if (secret.status !== "active") {
|
||||
issues.push({ key: row.key.trim() || secret.name, reason: secret.status });
|
||||
}
|
||||
}
|
||||
if (!issues.length) return null;
|
||||
return (
|
||||
<p className="text-[11px] text-amber-700 dark:text-amber-400 inline-flex items-start gap-1">
|
||||
<AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{issues.length} secret binding{issues.length === 1 ? "" : "s"} need attention:{" "}
|
||||
{issues.map((issue, idx) => (
|
||||
<span key={idx} className="font-mono">
|
||||
{issue.key}
|
||||
<span className="text-muted-foreground"> ({issue.reason})</span>
|
||||
{idx < issues.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
. Runs will fail until you remap or re-enable.
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
PAPERCLIP_* variables are injected automatically at runtime.
|
||||
Set KEY to the env var name the process expects, for example GH_TOKEN. Choose Secret to resolve a stored
|
||||
value at run start. PAPERCLIP_* variables are injected automatically.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// @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 { FileTree, buildFileTree } from "./FileTree";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("FileTree", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function row(path: string) {
|
||||
return container.querySelector(`[data-file-tree-path="${path}"]`) as HTMLDivElement | null;
|
||||
}
|
||||
|
||||
it("selects file rows and expands directory rows", () => {
|
||||
const onSelectFile = vi.fn();
|
||||
const onToggleDir = vi.fn();
|
||||
const nodes = buildFileTree({
|
||||
"README.md": "",
|
||||
"docs/guide.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile="README.md"
|
||||
expandedDirs={new Set(["docs"])}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleDir={onToggleDir}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(row("README.md")?.getAttribute("aria-selected")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
row("docs/guide.md")?.click();
|
||||
});
|
||||
expect(onSelectFile).toHaveBeenCalledWith("docs/guide.md");
|
||||
|
||||
act(() => {
|
||||
row("docs")?.click();
|
||||
});
|
||||
expect(onToggleDir).toHaveBeenCalledWith("docs");
|
||||
});
|
||||
|
||||
it("marks partially selected directories as indeterminate", () => {
|
||||
const nodes = buildFileTree({
|
||||
"docs/a.md": "",
|
||||
"docs/b.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set(["docs"])}
|
||||
checkedFiles={new Set(["docs/a.md"])}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={() => {}}
|
||||
onToggleCheck={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = row("docs")?.querySelector("input[type='checkbox']") as HTMLInputElement | null;
|
||||
expect(input?.checked).toBe(false);
|
||||
expect(input?.indeterminate).toBe(true);
|
||||
expect(row("docs")?.getAttribute("aria-checked")).toBe("mixed");
|
||||
});
|
||||
|
||||
it("renders file badges and host-only file extras", () => {
|
||||
const nodes = buildFileTree({
|
||||
"wiki/very-long-page-slug.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set(["wiki"])}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={() => {}}
|
||||
fileBadges={{
|
||||
"wiki/very-long-page-slug.md": {
|
||||
label: "fresh",
|
||||
status: "ok",
|
||||
tooltip: "Synced",
|
||||
},
|
||||
}}
|
||||
renderFileExtra={(node) => (
|
||||
node.kind === "file" ? <span data-testid="file-extra">{node.name.length} chars</span> : null
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("fresh");
|
||||
expect(container.querySelector("[title='Synced']")).not.toBeNull();
|
||||
expect(container.querySelector("[data-testid='file-extra']")?.textContent).toBe("22 chars");
|
||||
});
|
||||
|
||||
it("wraps long labels by default and can opt back into truncation", () => {
|
||||
const nodes = buildFileTree({
|
||||
"wiki/extremely-long-page-slug-that-wraps-on-mobile.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set(["wiki"])}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(row("wiki/extremely-long-page-slug-that-wraps-on-mobile.md")?.innerHTML).toContain("break-all");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set(["wiki"])}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={() => {}}
|
||||
wrapLabels={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(row("wiki/extremely-long-page-slug-that-wraps-on-mobile.md")?.innerHTML).toContain("truncate");
|
||||
});
|
||||
|
||||
it("supports tree keyboard expansion and checkbox toggling", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const onToggleCheck = vi.fn();
|
||||
const nodes = buildFileTree({
|
||||
"docs/a.md": "",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={null}
|
||||
expandedDirs={new Set()}
|
||||
onSelectFile={() => {}}
|
||||
onToggleDir={onToggleDir}
|
||||
onToggleCheck={onToggleCheck}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const docsRow = row("docs");
|
||||
act(() => {
|
||||
docsRow?.focus();
|
||||
docsRow?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
});
|
||||
expect(onToggleDir).toHaveBeenCalledWith("docs");
|
||||
|
||||
act(() => {
|
||||
docsRow?.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
|
||||
});
|
||||
expect(onToggleCheck).toHaveBeenCalledWith("docs", "dir");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode2,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
||||
import { Button } from "./ui/button";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
// -- Tree types --------------------------------------------------------------
|
||||
|
||||
export type FileTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "dir" | "file";
|
||||
children: FileTreeNode[];
|
||||
/** Optional per-node metadata (e.g. import action) */
|
||||
action?: string | null;
|
||||
};
|
||||
|
||||
export type FileTreeBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||
|
||||
export type FileTreeBadge = {
|
||||
label: string;
|
||||
status: FileTreeBadgeVariant;
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export type FileTreeTone = "default" | "warning" | "error" | "muted";
|
||||
|
||||
export type FileTreeEmptyState = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type FileTreeErrorState = {
|
||||
message: string;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
type VisibleFileTreeNode = {
|
||||
node: FileTreeNode;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
const TREE_BASE_INDENT = 16;
|
||||
const TREE_STEP_INDENT = 24;
|
||||
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
||||
|
||||
const fileTreeToneClass: Record<FileTreeTone, string | undefined> = {
|
||||
default: undefined,
|
||||
warning: "bg-amber-500/5 text-amber-700 dark:text-amber-300",
|
||||
error: "bg-destructive/5 text-destructive",
|
||||
muted: "opacity-50",
|
||||
};
|
||||
|
||||
// -- Helpers -----------------------------------------------------------------
|
||||
|
||||
export function buildFileTree(
|
||||
files: Record<string, unknown>,
|
||||
actionMap?: Map<string, string>,
|
||||
): FileTreeNode[] {
|
||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||
|
||||
for (const filePath of Object.keys(files)) {
|
||||
const segments = filePath.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
let currentPath = "";
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const isLeaf = i === segments.length - 1;
|
||||
let next = current.children.find((c) => c.name === segment);
|
||||
if (!next) {
|
||||
next = {
|
||||
name: segment,
|
||||
path: currentPath,
|
||||
kind: isLeaf ? "file" : "dir",
|
||||
children: [],
|
||||
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
|
||||
};
|
||||
current.children.push(next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
function sortNode(node: FileTreeNode) {
|
||||
node.children.sort((a, b) => {
|
||||
// Files before directories so PROJECT.md appears above tasks/
|
||||
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
node.children.forEach(sortNode);
|
||||
}
|
||||
|
||||
sortNode(root);
|
||||
return root.children;
|
||||
}
|
||||
|
||||
export function countFiles(nodes: FileTreeNode[]): number {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "file") count++;
|
||||
else count += countFiles(node.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function collectAllPaths(
|
||||
nodes: FileTreeNode[],
|
||||
type: "file" | "dir" | "all" = "all",
|
||||
): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (type === "all" || node.kind === type) paths.add(node.path);
|
||||
for (const p of collectAllPaths(node.children, type)) paths.add(p);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function fileIcon(name: string) {
|
||||
if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
function flattenVisibleNodes(
|
||||
nodes: FileTreeNode[],
|
||||
expandedDirs: Set<string>,
|
||||
depth = 0,
|
||||
): VisibleFileTreeNode[] {
|
||||
const flattened: VisibleFileTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
flattened.push({ node, depth });
|
||||
if (node.kind === "dir" && expandedDirs.has(node.path)) {
|
||||
flattened.push(...flattenVisibleNodes(node.children, expandedDirs, depth + 1));
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function checkboxState(node: FileTreeNode, checkedFiles: Set<string>) {
|
||||
if (node.kind === "file") {
|
||||
return {
|
||||
allChecked: checkedFiles.has(node.path),
|
||||
someChecked: false,
|
||||
};
|
||||
}
|
||||
|
||||
const childFiles = collectAllPaths(node.children, "file");
|
||||
const childFilePaths = [...childFiles];
|
||||
const allChecked = childFilePaths.length > 0 && childFilePaths.every((p) => checkedFiles.has(p));
|
||||
const someChecked = childFilePaths.some((p) => checkedFiles.has(p));
|
||||
return { allChecked, someChecked: someChecked && !allChecked };
|
||||
}
|
||||
|
||||
// -- Frontmatter helpers -----------------------------------------------------
|
||||
|
||||
export type FrontmatterData = Record<string, string | string[]>;
|
||||
|
||||
export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const data: FrontmatterData = {};
|
||||
const rawYaml = match[1];
|
||||
const body = match[2];
|
||||
|
||||
let currentKey: string | null = null;
|
||||
let currentList: string[] | null = null;
|
||||
|
||||
for (const line of rawYaml.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.startsWith("- ") && currentKey) {
|
||||
if (!currentList) currentList = [];
|
||||
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentKey && currentList) {
|
||||
data[currentKey] = currentList;
|
||||
currentList = null;
|
||||
currentKey = null;
|
||||
}
|
||||
|
||||
const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
|
||||
if (kvMatch) {
|
||||
const key = kvMatch[1];
|
||||
const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
|
||||
if (val === "null") {
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
if (val) {
|
||||
data[key] = val;
|
||||
currentKey = null;
|
||||
} else {
|
||||
currentKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentKey && currentList) {
|
||||
data[currentKey] = currentList;
|
||||
}
|
||||
|
||||
return Object.keys(data).length > 0 ? { data, body } : null;
|
||||
}
|
||||
|
||||
export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
||||
name: "Name",
|
||||
title: "Title",
|
||||
kind: "Kind",
|
||||
reportsTo: "Reports to",
|
||||
skills: "Skills",
|
||||
status: "Status",
|
||||
description: "Description",
|
||||
priority: "Priority",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
recurring: "Recurring",
|
||||
targetDate: "Target date",
|
||||
};
|
||||
|
||||
// -- File tree component -----------------------------------------------------
|
||||
|
||||
export type FileTreeProps = {
|
||||
nodes: FileTreeNode[];
|
||||
selectedFile: string | null;
|
||||
expandedDirs: Set<string>;
|
||||
checkedFiles?: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
|
||||
/** Serializable badge metadata keyed by path. This is safe to expose through plugin UI contracts. */
|
||||
fileBadges?: Record<string, FileTreeBadge | undefined>;
|
||||
/** Closed row tone metadata keyed by path. This avoids raw host class names in public contracts. */
|
||||
fileTones?: Record<string, FileTreeTone | undefined>;
|
||||
/** Internal-only escape hatch for current host call sites that need richer row content. */
|
||||
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
|
||||
/** @deprecated Use fileTones for public surfaces. Kept for compatibility with host-only callers. */
|
||||
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
|
||||
showCheckboxes?: boolean;
|
||||
/** Allow long file and directory names to wrap instead of forcing horizontal overflow. */
|
||||
wrapLabels?: boolean;
|
||||
loading?: boolean;
|
||||
error?: FileTreeErrorState | null;
|
||||
empty?: FileTreeEmptyState;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function FileTree({
|
||||
nodes,
|
||||
selectedFile,
|
||||
expandedDirs,
|
||||
checkedFiles,
|
||||
onToggleDir,
|
||||
onSelectFile,
|
||||
onToggleCheck,
|
||||
fileBadges,
|
||||
fileTones,
|
||||
renderFileExtra,
|
||||
fileRowClassName,
|
||||
showCheckboxes = true,
|
||||
wrapLabels = true,
|
||||
loading = false,
|
||||
error,
|
||||
empty,
|
||||
ariaLabel = "Files",
|
||||
}: FileTreeProps) {
|
||||
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
|
||||
const visibleNodes = useMemo(
|
||||
() => flattenVisibleNodes(nodes, expandedDirs),
|
||||
[expandedDirs, nodes],
|
||||
);
|
||||
const [focusedPath, setFocusedPath] = useState<string | null>(null);
|
||||
const rowRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
|
||||
function focusPath(path: string) {
|
||||
setFocusedPath(path);
|
||||
window.requestAnimationFrame(() => {
|
||||
rowRefs.current.get(path)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNode(node: FileTreeNode) {
|
||||
if (node.kind === "dir") onToggleDir(node.path);
|
||||
else onSelectFile(node.path);
|
||||
}
|
||||
|
||||
function handleRowKeyDown(event: KeyboardEvent<HTMLDivElement>, index: number, node: FileTreeNode) {
|
||||
switch (event.key) {
|
||||
case "ArrowDown": {
|
||||
event.preventDefault();
|
||||
const next = visibleNodes[Math.min(index + 1, visibleNodes.length - 1)];
|
||||
if (next) focusPath(next.node.path);
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
event.preventDefault();
|
||||
const previous = visibleNodes[Math.max(index - 1, 0)];
|
||||
if (previous) focusPath(previous.node.path);
|
||||
break;
|
||||
}
|
||||
case "ArrowRight":
|
||||
if (node.kind === "dir" && !expandedDirs.has(node.path)) {
|
||||
event.preventDefault();
|
||||
onToggleDir(node.path);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (node.kind === "dir" && expandedDirs.has(node.path)) {
|
||||
event.preventDefault();
|
||||
onToggleDir(node.path);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
toggleNode(node);
|
||||
break;
|
||||
case " ":
|
||||
if (showCheckboxes && onToggleCheck) {
|
||||
event.preventDefault();
|
||||
onToggleCheck(node.path, node.kind);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label={ariaLabel} role="tree" className="py-1">
|
||||
{[0, 1, 2, 3].map((row) => (
|
||||
<div key={row} className={cn("flex items-center gap-2 px-4", TREE_ROW_HEIGHT_CLASS)}>
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
|
||||
<Skeleton className={cn("h-3.5", row === 1 ? "w-3/5" : "w-4/5")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div aria-label={ariaLabel} role="tree" className="p-3">
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-level={1}
|
||||
className="flex min-h-9 items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap",
|
||||
statusBadge.error ?? statusBadgeDefault,
|
||||
)}
|
||||
>
|
||||
error
|
||||
</span>
|
||||
<span className="min-w-0 text-destructive">{error.message}</span>
|
||||
</div>
|
||||
{error.retry && (
|
||||
<Button type="button" size="xs" variant="outline" onClick={error.retry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div aria-label={ariaLabel} role="tree" className="p-3">
|
||||
<div className="rounded-md border border-dashed border-border px-4 py-8 text-center">
|
||||
<div className="text-sm font-medium">{empty?.title ?? "No files"}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{empty?.description ?? "Files will appear here when they are available."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label={ariaLabel} role="tree">
|
||||
{visibleNodes.map(({ node, depth }, index) => {
|
||||
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
||||
const { allChecked, someChecked } = checkboxState(node, effectiveCheckedFiles);
|
||||
const badge = fileBadges?.[node.path];
|
||||
const tone = fileTones?.[node.path] ?? "default";
|
||||
const extraClassName = node.kind === "file" ? fileRowClassName?.(node, allChecked) : undefined;
|
||||
const FileIcon = node.kind === "file" ? fileIcon(node.name) : null;
|
||||
const isSelected = node.kind === "file" && node.path === selectedFile;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.path}
|
||||
ref={(element) => {
|
||||
if (element) rowRefs.current.set(node.path, element);
|
||||
else rowRefs.current.delete(node.path);
|
||||
}}
|
||||
role="treeitem"
|
||||
aria-level={depth + 1}
|
||||
aria-expanded={node.kind === "dir" ? expanded : undefined}
|
||||
aria-selected={node.kind === "file" ? isSelected : undefined}
|
||||
aria-checked={showCheckboxes ? (someChecked ? "mixed" : allChecked) : undefined}
|
||||
tabIndex={(focusedPath ?? visibleNodes[0]?.node.path) === node.path ? 0 : -1}
|
||||
className={cn(
|
||||
node.kind === "dir"
|
||||
? showCheckboxes
|
||||
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground max-[480px]:grid-cols-[minmax(0,1fr)]"
|
||||
: "group flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
||||
TREE_ROW_HEIGHT_CLASS,
|
||||
isSelected && "text-foreground bg-accent/20",
|
||||
fileTreeToneClass[tone],
|
||||
extraClassName,
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset",
|
||||
)}
|
||||
style={{
|
||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||
}}
|
||||
onFocus={() => setFocusedPath(node.path)}
|
||||
onClick={() => toggleNode(node)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, index, node)}
|
||||
data-file-tree-path={node.path}
|
||||
>
|
||||
{showCheckboxes && (
|
||||
<label className="flex items-center pl-2" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(element) => {
|
||||
if (element) element.indeterminate = someChecked;
|
||||
}}
|
||||
onChange={() => onToggleCheck?.(node.path, node.kind)}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left">
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{node.kind === "dir" ? (
|
||||
expanded ? (
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
)
|
||||
) : FileIcon ? (
|
||||
<FileIcon className="h-3.5 w-3.5" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
||||
{node.name}
|
||||
</span>
|
||||
</span>
|
||||
{badge && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-3 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||
statusBadge[badge.status] ?? statusBadgeDefault,
|
||||
)}
|
||||
title={badge.tooltip}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
{node.kind === "file" && renderFileExtra?.(node, allChecked)}
|
||||
{node.kind === "dir" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50 max-[480px]:hidden"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleDir(node.path);
|
||||
}}
|
||||
aria-label={expanded ? `Collapse ${node.name}` : `Expand ${node.name}`}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface IdentityProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function deriveInitials(name: string): string {
|
||||
export function deriveInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
|
||||
@@ -13,7 +13,7 @@ export function InstanceSidebar() {
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
|
||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
|
||||
<span className="flex-1 text-sm font-bold text-foreground truncate">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const baseAgent = {
|
||||
id: "agent-1",
|
||||
companyId: "co-1",
|
||||
name: "ClaudeCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
} as unknown as Agent;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
describe("IssueAssignedBacklogNotice", () => {
|
||||
it("renders nothing when status is not backlog", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueAssignedBacklogNotice
|
||||
issueStatus="todo"
|
||||
assigneeAgent={baseAgent}
|
||||
assigneeUserId={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="issue-assigned-backlog-notice"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when there is no assignee", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueAssignedBacklogNotice
|
||||
issueStatus="backlog"
|
||||
assigneeAgent={null}
|
||||
assigneeUserId={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="issue-assigned-backlog-notice"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("warns when an agent is assigned and the issue is parked in backlog", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueAssignedBacklogNotice
|
||||
issueStatus="backlog"
|
||||
assigneeAgent={baseAgent}
|
||||
assigneeUserId={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const notice = container.querySelector('[data-testid="issue-assigned-backlog-notice"]');
|
||||
expect(notice).not.toBeNull();
|
||||
expect(notice?.textContent).toContain("Parked");
|
||||
expect(notice?.textContent).toContain("ClaudeCoder");
|
||||
});
|
||||
|
||||
it("calls onResume when the resume button is clicked", () => {
|
||||
const onResume = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueAssignedBacklogNotice
|
||||
issueStatus="backlog"
|
||||
assigneeAgent={baseAgent}
|
||||
assigneeUserId={null}
|
||||
onResume={onResume}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const button = container.querySelector('[data-testid="issue-assigned-backlog-resume"]') as HTMLButtonElement | null;
|
||||
expect(button).not.toBeNull();
|
||||
act(() => {
|
||||
button?.click();
|
||||
});
|
||||
expect(onResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables the resume button while resuming", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueAssignedBacklogNotice
|
||||
issueStatus="backlog"
|
||||
assigneeAgent={baseAgent}
|
||||
assigneeUserId={null}
|
||||
onResume={() => undefined}
|
||||
resuming
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const button = container.querySelector('[data-testid="issue-assigned-backlog-resume"]') as HTMLButtonElement | null;
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(button?.textContent).toContain("Resuming");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Flag } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface IssueAssignedBacklogNoticeProps {
|
||||
issueStatus: string;
|
||||
assigneeAgent: Agent | null;
|
||||
assigneeUserId?: string | null;
|
||||
onResume?: () => void;
|
||||
resuming?: boolean;
|
||||
}
|
||||
|
||||
export function IssueAssignedBacklogNotice({
|
||||
issueStatus,
|
||||
assigneeAgent,
|
||||
assigneeUserId,
|
||||
onResume,
|
||||
resuming,
|
||||
}: IssueAssignedBacklogNoticeProps) {
|
||||
if (issueStatus !== "backlog") return null;
|
||||
if (!assigneeAgent && !assigneeUserId) return null;
|
||||
|
||||
const assigneeLabel = assigneeAgent?.name ?? "the assignee";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="issue-assigned-backlog-notice"
|
||||
data-issue-status={issueStatus}
|
||||
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Flag className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<p className="leading-5">
|
||||
<span className="font-medium">Parked</span> —{" "}
|
||||
<span className="font-medium">{assigneeLabel}</span> will not be woken until status changes to{" "}
|
||||
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">todo</code> or{" "}
|
||||
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">in_progress</code>.
|
||||
</p>
|
||||
{assigneeAgent ? (
|
||||
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
|
||||
Comments still wake the assignee for questions or triage. Leave this parked only if the work is intentionally on hold.
|
||||
</p>
|
||||
) : null}
|
||||
{onResume ? (
|
||||
<div className="pt-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 border-amber-400/70 bg-background/80 text-amber-950 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
||||
onClick={onResume}
|
||||
disabled={resuming}
|
||||
data-testid="issue-assigned-backlog-resume"
|
||||
>
|
||||
{resuming ? "Resuming…" : "Resume now"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AnchorHTMLAttributes, ReactElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root?.render(element));
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("IssueBlockedNotice", () => {
|
||||
it("renders a successful-run next-step notice without requiring blockers", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="in_progress"
|
||||
blockers={[]}
|
||||
agentName="CodexCoder"
|
||||
successfulRunHandoff={{
|
||||
state: "required",
|
||||
required: true,
|
||||
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
correctiveRunId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
detectedProgressSummary: "Updated the plan and left follow-up work.",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(node.textContent).toContain("This issue still needs a next step.");
|
||||
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
|
||||
expect(node.textContent).toContain("Detected progress: Updated the plan");
|
||||
expect(node.textContent).not.toContain("Work on this issue is blocked until");
|
||||
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not render when the issue is done even if a stale handoff state is required", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="done"
|
||||
blockers={[]}
|
||||
agentName="CodexCoder"
|
||||
successfulRunHandoff={{
|
||||
state: "required",
|
||||
required: true,
|
||||
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
correctiveRunId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
detectedProgressSummary: "Updated the plan and left follow-up work.",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(node.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("does not render when the issue is cancelled even if blockers remain", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="cancelled"
|
||||
blockers={[
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "PAP-123",
|
||||
title: "Blocker",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(node.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
|
||||
import { AlertTriangle, Flag } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
|
||||
export function IssueBlockedNotice({
|
||||
issueStatus,
|
||||
blockers,
|
||||
blockerAttention,
|
||||
successfulRunHandoff,
|
||||
agentName,
|
||||
}: {
|
||||
issueStatus?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
agentName?: string | null;
|
||||
}) {
|
||||
if (blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
if (issueStatus === "done" || issueStatus === "cancelled") return null;
|
||||
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
|
||||
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
|
||||
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
|
||||
const terminalBlockers = blockers
|
||||
@@ -20,6 +28,24 @@ export function IssueBlockedNotice({
|
||||
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
|
||||
|
||||
const isStalled = blockerAttention?.state === "stalled";
|
||||
const parkedBlockers = (() => {
|
||||
const seen = new Set<string>();
|
||||
const collected: IssueRelationIssueSummary[] = [];
|
||||
const sources: IssueRelationIssueSummary[] = [...blockers];
|
||||
for (const blocker of blockers) {
|
||||
for (const terminal of blocker.terminalBlockers ?? []) {
|
||||
sources.push(terminal);
|
||||
}
|
||||
}
|
||||
for (const blocker of sources) {
|
||||
if (!isAssignedBacklogBlocker(blocker)) continue;
|
||||
if (seen.has(blocker.id)) continue;
|
||||
seen.add(blocker.id);
|
||||
collected.push(blocker);
|
||||
}
|
||||
return collected;
|
||||
})();
|
||||
const showParkedRow = parkedBlockers.length > 0;
|
||||
const stalledLeafIdentifier =
|
||||
blockerAttention?.sampleStalledBlockerIdentifier ?? blockerAttention?.sampleBlockerIdentifier ?? null;
|
||||
const stalledLeafBlockers = (() => {
|
||||
@@ -61,39 +87,99 @@ export function IssueBlockedNotice({
|
||||
return (
|
||||
<div
|
||||
data-blocker-attention-state={blockerAttention?.state}
|
||||
data-successful-run-handoff={showSuccessfulRunHandoff ? "required" : undefined}
|
||||
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="leading-5">
|
||||
{blockers.length > 0
|
||||
? isStalled
|
||||
? stalledLeafBlockers.length > 1
|
||||
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
|
||||
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
|
||||
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
|
||||
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
|
||||
</p>
|
||||
{blockers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
{showSuccessfulRunHandoff ? (
|
||||
<>
|
||||
<p className="font-medium leading-5">This issue still needs a next step.</p>
|
||||
<p className="leading-5">
|
||||
A run finished successfully, but this issue is still open in{" "}
|
||||
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">
|
||||
in_progress
|
||||
</code>{" "}
|
||||
with no clear owner for the next action.
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs leading-5 text-amber-900 dark:text-amber-100">
|
||||
<li>Mark it done or cancelled.</li>
|
||||
<li>Send it for review or ask for input.</li>
|
||||
<li>Mark it blocked with a blocker owner.</li>
|
||||
<li>Delegate follow-up work or queue a continuation.</li>
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-1.5 text-xs">
|
||||
{successfulRunHandoff.sourceRunId && successfulRunHandoff.assigneeAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${successfulRunHandoff.assigneeAgentId}/runs/${successfulRunHandoff.sourceRunId}`}
|
||||
className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
||||
>
|
||||
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
|
||||
</Link>
|
||||
) : successfulRunHandoff.sourceRunId ? (
|
||||
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
|
||||
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 text-amber-900 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
|
||||
Corrective wake queued for {agentName ?? "the assignee"}
|
||||
</span>
|
||||
</div>
|
||||
{successfulRunHandoff.detectedProgressSummary ? (
|
||||
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
|
||||
Detected progress: {successfulRunHandoff.detectedProgressSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{showStalledRow ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Stalled in review
|
||||
</span>
|
||||
{stalledLeafBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : terminalBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Ultimately waiting on
|
||||
</span>
|
||||
{terminalBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
|
||||
<div className="border-t border-amber-300/60 pt-1.5 dark:border-amber-500/30" />
|
||||
) : null}
|
||||
{blockers.length > 0 || issueStatus === "blocked" ? (
|
||||
<>
|
||||
<p className="leading-5">
|
||||
{blockers.length > 0
|
||||
? isStalled
|
||||
? stalledLeafBlockers.length > 1
|
||||
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
|
||||
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
|
||||
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
|
||||
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
|
||||
</p>
|
||||
{blockers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
{showStalledRow ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Stalled in review
|
||||
</span>
|
||||
{stalledLeafBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : terminalBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Ultimately waiting on
|
||||
</span>
|
||||
{terminalBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
{showParkedRow ? (
|
||||
<div
|
||||
data-testid="issue-blocked-notice-parked-row"
|
||||
className="flex flex-wrap items-center gap-1.5 pt-0.5"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
<Flag className="h-3 w-3" aria-hidden />
|
||||
Blocked by parked work
|
||||
</span>
|
||||
{parkedBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -346,6 +346,104 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the composer in planning mode when the issue is in planning mode", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
issueWorkMode="planning"
|
||||
onWorkModeChange={() => {}}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const composer = container.querySelector('[data-testid="issue-chat-composer"]');
|
||||
expect(composer).not.toBeNull();
|
||||
expect(composer?.getAttribute("data-pending-work-mode")).toBe("planning");
|
||||
expect(composer?.className).toContain("amber");
|
||||
|
||||
const toggle = container.querySelector(
|
||||
'[data-testid="issue-chat-composer-work-mode-toggle"]',
|
||||
);
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle?.getAttribute("data-pending-work-mode")).toBe("planning");
|
||||
expect(toggle?.textContent).toContain("Planning");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the planning chip on a standard issue and exposes the toggle through the menu", () => {
|
||||
const root = createRoot(container);
|
||||
const onWorkModeChange = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
issueWorkMode="standard"
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="issue-chat-composer-work-mode-toggle"]'),
|
||||
).toBeNull();
|
||||
const composer = container.querySelector('[data-testid="issue-chat-composer"]');
|
||||
expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard");
|
||||
expect(composer?.className).not.toContain("amber");
|
||||
|
||||
const menuTrigger = container.querySelector(
|
||||
'[data-testid="issue-chat-composer-work-mode-menu"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(menuTrigger).not.toBeNull();
|
||||
act(() => {
|
||||
menuTrigger?.click();
|
||||
});
|
||||
|
||||
const menuItem = document.querySelector(
|
||||
'[data-testid="issue-chat-composer-work-mode-menu-toggle"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(menuItem).not.toBeNull();
|
||||
expect(menuItem?.textContent).toContain("Switch to planning");
|
||||
|
||||
act(() => {
|
||||
menuItem?.click();
|
||||
});
|
||||
|
||||
expect(onWorkModeChange).not.toHaveBeenCalled();
|
||||
expect(composer?.getAttribute("data-pending-work-mode")).toBe("planning");
|
||||
expect(composer?.className).toContain("amber");
|
||||
|
||||
const visibleChip = container.querySelector(
|
||||
'[data-testid="issue-chat-composer-work-mode-toggle"]',
|
||||
);
|
||||
expect(visibleChip).not.toBeNull();
|
||||
expect(visibleChip?.textContent).toContain("Planning");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("virtualizes long merged threads so only a windowed slice mounts", () => {
|
||||
const root = createRoot(container);
|
||||
const totalMergedRows =
|
||||
@@ -836,6 +934,9 @@ describe("IssueChatThread", () => {
|
||||
authorAgentId: "agent-perf-codex",
|
||||
authorUserId: null,
|
||||
body: "Older loaded comment",
|
||||
authorType: "agent" as const,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
};
|
||||
@@ -1056,6 +1157,9 @@ describe("IssueChatThread", () => {
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: "Agent summary with **markdown**",
|
||||
authorType: "agent" as const,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
}];
|
||||
@@ -1135,6 +1239,9 @@ describe("IssueChatThread", () => {
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "Please continue validation.",
|
||||
authorType: "user",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
followUpRequested: true,
|
||||
createdAt: new Date("2026-03-11T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
|
||||
@@ -1588,6 +1695,9 @@ describe("IssueChatThread", () => {
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: "Agent summary",
|
||||
authorType: "agent",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
}]}
|
||||
@@ -1624,6 +1734,9 @@ describe("IssueChatThread", () => {
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "Need a quick update",
|
||||
authorType: "user",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
queueState: "queued",
|
||||
queueReason: "hold",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
@@ -1653,6 +1766,9 @@ describe("IssueChatThread", () => {
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "Queue behind active run",
|
||||
authorType: "user",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
queueState: "queued",
|
||||
queueReason: "active_run",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
@@ -1997,6 +2113,9 @@ describe("IssueChatThread", () => {
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "hello",
|
||||
authorType: "user",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-22T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-22T12:00:00.000Z"),
|
||||
}]}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -36,6 +37,8 @@ import type {
|
||||
IssueAttachment,
|
||||
IssueBlockerAttention,
|
||||
IssueRelationIssueSummary,
|
||||
SuccessfulRunHandoffState,
|
||||
IssueWorkMode,
|
||||
} from "@paperclipai/shared";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||
@@ -59,7 +62,12 @@ import type {
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions";
|
||||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import {
|
||||
formatTimelineWorkspaceLabel,
|
||||
type IssueTimelineAssignee,
|
||||
type IssueTimelineEvent,
|
||||
type IssueTimelineWorkspace,
|
||||
} from "../lib/issue-timeline-events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -93,6 +101,23 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { useOptionalToastActions } from "../context/ToastContext";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
isSuccessfulRunHandoffComment,
|
||||
isSuccessfulRunHandoffEscalationComment,
|
||||
} from "../lib/successful-run-handoff";
|
||||
import {
|
||||
SystemNotice,
|
||||
type SystemNoticeMetadataRow,
|
||||
type SystemNoticeMetadataSection,
|
||||
} from "./SystemNotice";
|
||||
import {
|
||||
buildSystemNoticeProps,
|
||||
mapCommentMetadataToSystemNoticeSections,
|
||||
} from "../lib/system-notice-comment";
|
||||
import type {
|
||||
IssueCommentMetadata,
|
||||
IssueCommentPresentation,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
describeToolInput,
|
||||
displayToolName,
|
||||
@@ -106,8 +131,9 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
|
||||
|
||||
interface IssueChatMessageContext {
|
||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||
@@ -143,11 +169,15 @@ interface IssueChatMessageContext {
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
issueStatus?: string;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
}
|
||||
|
||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
feedbackTermsUrl: null,
|
||||
issueStatus: undefined,
|
||||
successfulRunHandoff: null,
|
||||
});
|
||||
|
||||
export function resolveAssistantMessageFoldedState(args: {
|
||||
@@ -250,6 +280,8 @@ interface IssueChatComposerProps {
|
||||
composerDisabledReason?: string | null;
|
||||
composerHint?: string | null;
|
||||
issueStatus?: string;
|
||||
issueWorkMode?: IssueWorkMode;
|
||||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface IssueChatThreadProps {
|
||||
@@ -264,6 +296,10 @@ interface IssueChatThreadProps {
|
||||
activeRun?: ActiveRunForIssue | null;
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
assigneeUserId?: string | null;
|
||||
onResumeFromBacklog?: () => Promise<void> | void;
|
||||
resumeFromBacklogPending?: boolean;
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
issueStatus?: string;
|
||||
@@ -292,6 +328,7 @@ interface IssueChatThreadProps {
|
||||
mentions?: MentionOption[];
|
||||
composerDisabledReason?: string | null;
|
||||
composerHint?: string | null;
|
||||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
||||
showComposer?: boolean;
|
||||
showJumpToLatest?: boolean;
|
||||
emptyMessage?: string;
|
||||
@@ -321,6 +358,7 @@ interface IssueChatThreadProps {
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
composerRef?: Ref<IssueChatComposerHandle>;
|
||||
issueWorkMode?: IssueWorkMode;
|
||||
/**
|
||||
* Hook for the parent to refetch comments when the user explicitly asks
|
||||
* to jump to the latest comment. Used to make sure the absolute newest
|
||||
@@ -583,6 +621,9 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||
|
||||
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||
const { onImageClick } = useContext(IssueChatCtx);
|
||||
if (isSuccessfulRunHandoffComment(text)) {
|
||||
return <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
|
||||
}
|
||||
return (
|
||||
<MarkdownBody
|
||||
className="text-sm leading-6"
|
||||
@@ -595,6 +636,41 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: {
|
||||
);
|
||||
});
|
||||
|
||||
export function SuccessfulRunHandoffCommentCallout({
|
||||
text,
|
||||
recessed,
|
||||
onImageClick,
|
||||
}: {
|
||||
text: string;
|
||||
recessed?: boolean;
|
||||
onImageClick?: (src: string) => void;
|
||||
}) {
|
||||
const escalated = isSuccessfulRunHandoffEscalationComment(text);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-2.5 text-sm shadow-sm",
|
||||
escalated
|
||||
? "border-red-500/35 bg-red-500/10 text-red-950 dark:text-red-100"
|
||||
: "border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
|
||||
)}
|
||||
style={recessed ? { opacity: 0.55 } : undefined}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle
|
||||
className={cn(
|
||||
"mt-1 h-4 w-4 shrink-0",
|
||||
escalated ? "text-red-600 dark:text-red-300" : "text-amber-600 dark:text-amber-300",
|
||||
)}
|
||||
/>
|
||||
<MarkdownBody className="min-w-0 text-sm leading-6" softBreaks onImageClick={onImageClick}>
|
||||
{text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function humanizeValue(value: string | null) {
|
||||
if (!value) return "None";
|
||||
return value.replace(/_/g, " ");
|
||||
@@ -1901,6 +1977,366 @@ function ExpiredRequestConfirmationActivity({
|
||||
);
|
||||
}
|
||||
|
||||
function isIssueCommentPresentation(value: unknown): value is IssueCommentPresentation {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return v.kind === "system_notice" || v.kind === "message";
|
||||
}
|
||||
|
||||
function isIssueCommentMetadata(value: unknown): value is IssueCommentMetadata {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return v.version === 1 && Array.isArray(v.sections);
|
||||
}
|
||||
|
||||
function issueStatusIsTerminalDisposition(issueStatus: string | undefined) {
|
||||
return issueStatus === "done" || issueStatus === "cancelled";
|
||||
}
|
||||
|
||||
function sourceRunIdFromSuccessfulRunHandoffMetadata(metadata: IssueCommentMetadata | null) {
|
||||
if (metadata?.sourceRunId) return metadata.sourceRunId;
|
||||
const runLinks = [];
|
||||
for (const section of metadata?.sections ?? []) {
|
||||
for (const row of section.rows) {
|
||||
if (row.type === "run_link") runLinks.push(row.runId);
|
||||
}
|
||||
}
|
||||
return runLinks.length === 1 ? runLinks[0] : null;
|
||||
}
|
||||
|
||||
function isStaleSuccessfulRunHandoffNotice(input: {
|
||||
bodyText: string;
|
||||
issueStatus?: string;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
runId?: string | null;
|
||||
metadata: IssueCommentMetadata | null;
|
||||
}) {
|
||||
if (!isSuccessfulRunHandoffComment(input.bodyText)) return false;
|
||||
|
||||
const currentHandoff = input.successfulRunHandoff ?? null;
|
||||
if (currentHandoff?.state === "resolved") return true;
|
||||
if (issueStatusIsTerminalDisposition(input.issueStatus)) return true;
|
||||
|
||||
const noticeSourceRunId = sourceRunIdFromSuccessfulRunHandoffMetadata(input.metadata) ?? input.runId ?? null;
|
||||
if (
|
||||
noticeSourceRunId
|
||||
&& currentHandoff?.sourceRunId
|
||||
&& noticeSourceRunId !== currentHandoff.sourceRunId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function StaleDispositionWarningMetadataRow({ row }: { row: SystemNoticeMetadataRow }) {
|
||||
const label = (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{row.label}
|
||||
</span>
|
||||
);
|
||||
const value = (() => {
|
||||
switch (row.kind) {
|
||||
case "text":
|
||||
return <span>{row.value}</span>;
|
||||
case "code":
|
||||
return (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
|
||||
{row.value}
|
||||
</code>
|
||||
);
|
||||
case "issue": {
|
||||
const content = (
|
||||
<>
|
||||
<span>{row.identifier}</span>
|
||||
{row.title ? <span className="text-muted-foreground"> - {row.title}</span> : null}
|
||||
</>
|
||||
);
|
||||
return row.href ? (
|
||||
<a href={row.href} className="font-medium text-foreground underline-offset-2 hover:underline">
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{content}</span>
|
||||
);
|
||||
}
|
||||
case "agent":
|
||||
return row.href ? (
|
||||
<a href={row.href} className="font-medium text-foreground underline-offset-2 hover:underline">
|
||||
{row.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{row.name}</span>
|
||||
);
|
||||
case "run": {
|
||||
const runShort = row.runId.length > 12 ? `${row.runId.slice(0, 8)}...` : row.runId;
|
||||
const content = (
|
||||
<>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
|
||||
{runShort}
|
||||
</code>
|
||||
{row.status ? <span>{row.status}</span> : null}
|
||||
</>
|
||||
);
|
||||
return row.href ? (
|
||||
<a href={row.href} className="inline-flex items-center gap-1.5 underline-offset-2 hover:underline">
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">{content}</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-2 text-xs leading-5">
|
||||
{label}
|
||||
<div className="min-w-0 break-words text-foreground/80">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function metadataRowKey(row: SystemNoticeMetadataRow) {
|
||||
switch (row.kind) {
|
||||
case "issue":
|
||||
return `issue:${row.label}:${row.identifier}:${row.href ?? ""}:${row.title ?? ""}`;
|
||||
case "agent":
|
||||
return `agent:${row.label}:${row.name}:${row.href ?? ""}`;
|
||||
case "run":
|
||||
return `run:${row.label}:${row.runId}:${row.href ?? ""}:${row.status ?? ""}`;
|
||||
default:
|
||||
return `${row.kind}:${row.label}:${row.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
function metadataSectionKey(section: SystemNoticeMetadataSection) {
|
||||
return `${section.title ?? "details"}:${section.rows.map(metadataRowKey).join("|")}`;
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isTimelineWorkspace(value: unknown): value is IssueTimelineWorkspace {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const workspace = value as Record<string, unknown>;
|
||||
return isNullableString(workspace.label)
|
||||
&& isNullableString(workspace.projectWorkspaceId)
|
||||
&& isNullableString(workspace.executionWorkspaceId)
|
||||
&& isNullableString(workspace.mode);
|
||||
}
|
||||
|
||||
function isTimelineWorkspaceChange(value: unknown): value is NonNullable<IssueTimelineEvent["workspaceChange"]> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const change = value as Record<string, unknown>;
|
||||
return isTimelineWorkspace(change.from) && isTimelineWorkspace(change.to);
|
||||
}
|
||||
|
||||
function StaleDispositionWarningDetails({
|
||||
sections,
|
||||
}: {
|
||||
sections: SystemNoticeMetadataSection[];
|
||||
}) {
|
||||
if (sections.length === 0) {
|
||||
return <div className="text-xs leading-5 text-muted-foreground">No additional details.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-left">
|
||||
{sections.map((section) => (
|
||||
<div key={metadataSectionKey(section)} className="space-y-1.5">
|
||||
{section.title ? (
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{section.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{section.rows.map((row) => (
|
||||
<StaleDispositionWarningMetadataRow key={metadataRowKey(row)} row={row} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaleDispositionWarningRow({
|
||||
anchorId,
|
||||
message,
|
||||
metadata,
|
||||
runAgentId,
|
||||
}: {
|
||||
anchorId?: string;
|
||||
message: ThreadMessage;
|
||||
metadata: IssueCommentMetadata | null;
|
||||
runAgentId?: string | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const detailsId = useId();
|
||||
const sections = mapCommentMetadataToSystemNoticeSections(metadata, { runAgentId });
|
||||
|
||||
return (
|
||||
<div id={anchorId} data-testid="stale-disposition-warning">
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<span className="size-6 shrink-0" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={detailsId}
|
||||
className="group flex w-full items-center gap-2 py-0.5 text-left"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
Stale disposition warning
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
{message.createdAt ? (
|
||||
<span data-testid="stale-disposition-warning-time" className="text-[11px] text-muted-foreground/50">
|
||||
{commentDateLabel(message.createdAt)}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground/40 transition-transform", open && "rotate-180")} />
|
||||
</span>
|
||||
</button>
|
||||
<div id={detailsId} hidden={!open} className="space-y-1 py-1">
|
||||
<StaleDispositionWarningDetails sections={sections} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemNoticeCommentRow({
|
||||
message,
|
||||
anchorId,
|
||||
}: {
|
||||
message: ThreadMessage;
|
||||
anchorId?: string;
|
||||
}) {
|
||||
const { onImageClick, agentMap, issueStatus, successfulRunHandoff } = useContext(IssueChatCtx);
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null;
|
||||
const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null;
|
||||
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||||
const authorType = typeof custom.authorType === "string" ? custom.authorType : null;
|
||||
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
|
||||
const bodyText = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
const staleSuccessfulRunHandoffNotice = isStaleSuccessfulRunHandoffNotice({
|
||||
bodyText,
|
||||
issueStatus,
|
||||
successfulRunHandoff,
|
||||
runId,
|
||||
metadata: commentMetadata,
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
|
||||
const source = (() => {
|
||||
const runAgentName = runAgentId ? agentMap?.get(runAgentId)?.name ?? null : null;
|
||||
if (authorType === "system") {
|
||||
const label = runAgentName ?? "Paperclip";
|
||||
if (runAgentId && runId) return { label, href: `/agents/${runAgentId}/runs/${runId}` };
|
||||
return { label };
|
||||
}
|
||||
if (runAgentId && runId) {
|
||||
return { label: authorName ?? runAgentName ?? "Paperclip", href: `/agents/${runAgentId}/runs/${runId}` };
|
||||
}
|
||||
if (authorName) return { label: authorName };
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const props = buildSystemNoticeProps({
|
||||
presentation,
|
||||
metadata: commentMetadata,
|
||||
body: (
|
||||
<MarkdownBody className="text-sm leading-6" softBreaks onImageClick={onImageClick}>
|
||||
{bodyText}
|
||||
</MarkdownBody>
|
||||
),
|
||||
timestamp: message.createdAt ? new Date(message.createdAt).toISOString() : undefined,
|
||||
source,
|
||||
runAgentId,
|
||||
});
|
||||
|
||||
const handleCopy = () => {
|
||||
void navigator.clipboard.writeText(bodyText).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
if (!anchorId || typeof window === "undefined") return;
|
||||
const url = `${window.location.origin}${window.location.pathname}#${anchorId}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (staleSuccessfulRunHandoffNotice) {
|
||||
return (
|
||||
<StaleDispositionWarningRow
|
||||
anchorId={anchorId}
|
||||
message={message}
|
||||
metadata={commentMetadata}
|
||||
runAgentId={runAgentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id={anchorId} className="group">
|
||||
<div className="py-1">
|
||||
<SystemNotice {...props} />
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{anchorId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy link"
|
||||
aria-label="Copy link to system notice"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{copiedLink ? <Check className="h-3.5 w-3.5" /> : <Paperclip className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy notice text"
|
||||
aria-label="Copy system notice"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
const {
|
||||
agentMap,
|
||||
@@ -1929,10 +2365,20 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
to: IssueTimelineAssignee;
|
||||
}
|
||||
: null;
|
||||
const workspaceChange = isTimelineWorkspaceChange(custom.workspaceChange) ? custom.workspaceChange : null;
|
||||
const interaction = isIssueThreadInteraction(custom.interaction)
|
||||
? custom.interaction
|
||||
: null;
|
||||
|
||||
if (custom.kind === "system_notice") {
|
||||
return (
|
||||
<SystemNoticeCommentRow
|
||||
message={message}
|
||||
anchorId={anchorId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (custom.kind === "interaction" && interaction) {
|
||||
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
|
||||
return (
|
||||
@@ -2007,6 +2453,21 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{workspaceChange ? (
|
||||
<div className={cn("flex flex-wrap items-center gap-1.5 text-xs", isCurrentUser && "justify-end")}>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Workspace
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimelineWorkspaceLabel(workspaceChange.from)}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">
|
||||
{formatTimelineWorkspaceLabel(workspaceChange.to)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2636,6 +3097,8 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
composerDisabledReason = null,
|
||||
composerHint = null,
|
||||
issueStatus,
|
||||
issueWorkMode,
|
||||
onWorkModeChange,
|
||||
}, forwardedRef) {
|
||||
const api = useAui();
|
||||
const toastActions = useOptionalToastActions();
|
||||
@@ -2648,6 +3111,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const [unassignedConfirmed, setUnassignedConfirmed] = useState(false);
|
||||
const resolvedIssueWorkMode: IssueWorkMode = issueWorkMode ?? "standard";
|
||||
const [pendingWorkMode, setPendingWorkMode] = useState<IssueWorkMode>(resolvedIssueWorkMode);
|
||||
const [workModeMenuOpen, setWorkModeMenuOpen] = useState(false);
|
||||
const canToggleWorkMode = typeof onWorkModeChange === "function";
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -2698,6 +3165,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
setUnassignedConfirmed(false);
|
||||
}, [reassignTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingWorkMode(resolvedIssueWorkMode);
|
||||
}, [resolvedIssueWorkMode]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: focusComposer,
|
||||
restoreDraft: (submittedBody: string) => {
|
||||
@@ -2740,10 +3211,14 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const submittedBody = trimmed;
|
||||
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
|
||||
|
||||
const workModeChanged = pendingWorkMode !== resolvedIssueWorkMode;
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
setUnassignedConfirmed(false);
|
||||
try {
|
||||
if (workModeChanged && onWorkModeChange) {
|
||||
await onWorkModeChange(pendingWorkMode);
|
||||
}
|
||||
const appendPromise = api.thread().append({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: submittedBody }],
|
||||
@@ -2901,12 +3376,16 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
);
|
||||
}
|
||||
|
||||
const isPlanning = pendingWorkMode === "planning";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={composerContainerRef}
|
||||
data-testid="issue-chat-composer"
|
||||
data-pending-work-mode={pendingWorkMode}
|
||||
className={cn(
|
||||
"relative rounded-md border border-border/70 bg-background/95 p-[15px] shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur transition-[border-color,background-color,box-shadow] duration-150 supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]",
|
||||
isPlanning && "border-amber-500/60 bg-amber-50/60 supports-[backdrop-filter]:bg-amber-50/40 dark:border-amber-500/50 dark:bg-amber-500/[0.07] dark:supports-[backdrop-filter]:bg-amber-500/[0.07]",
|
||||
isDragOver && "border-primary/45 bg-background shadow-[0_-12px_28px_rgba(15,23,42,0.08),0_0_0_1px_hsl(var(--primary)/0.16)]",
|
||||
)}
|
||||
onDragEnterCapture={handleFileDragEnter}
|
||||
@@ -2998,25 +3477,77 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
{(onImageUpload || onAttachImage) ? (
|
||||
<div className="mr-auto flex items-center gap-3">
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach file"
|
||||
<div className="mr-auto flex items-center gap-2">
|
||||
{(onImageUpload || onAttachImage) ? (
|
||||
<>
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach file"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{canToggleWorkMode ? (
|
||||
<Popover open={workModeMenuOpen} onOpenChange={setWorkModeMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
data-testid="issue-chat-composer-work-mode-menu"
|
||||
title="More composer options"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="start">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="issue-chat-composer-work-mode-menu-toggle"
|
||||
data-pending-work-mode={pendingWorkMode}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
isPlanning ? "text-amber-700 dark:text-amber-300" : "text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
setPendingWorkMode((prev) => (prev === "planning" ? "standard" : "planning"));
|
||||
setWorkModeMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{isPlanning ? (
|
||||
<Hammer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
) : (
|
||||
<ClipboardList className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" aria-hidden />
|
||||
)}
|
||||
<span>{isPlanning ? "Switch to standard" : "Switch to planning"}</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
{canToggleWorkMode && isPlanning ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="issue-chat-composer-work-mode-toggle"
|
||||
data-pending-work-mode={pendingWorkMode}
|
||||
aria-pressed
|
||||
title="Planning mode is on for this submission. Click to switch to Standard."
|
||||
onClick={() => setPendingWorkMode("standard")}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/60 bg-amber-500/15 px-2 py-1 text-xs text-amber-800 transition-colors hover:bg-amber-500/25 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<ClipboardList className="h-3.5 w-3.5" aria-hidden />
|
||||
<span>Planning</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{enableReassign && reassignOptions.length > 0 ? (
|
||||
<InlineEntitySelector
|
||||
@@ -3077,6 +3608,7 @@ export function IssueChatThread({
|
||||
activeRun = null,
|
||||
blockedBy = [],
|
||||
blockerAttention = null,
|
||||
successfulRunHandoff = null,
|
||||
companyId,
|
||||
projectId,
|
||||
issueStatus,
|
||||
@@ -3119,7 +3651,12 @@ export function IssueChatThread({
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
composerRef,
|
||||
issueWorkMode,
|
||||
onWorkModeChange,
|
||||
onRefreshLatestComments,
|
||||
assigneeUserId = null,
|
||||
onResumeFromBacklog,
|
||||
resumeFromBacklogPending = false,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
const lastScrolledHashRef = useRef<string | null>(null);
|
||||
@@ -3597,6 +4134,8 @@ export function IssueChatThread({
|
||||
onRejectInteraction: stableOnRejectInteraction,
|
||||
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
|
||||
onCancelInteraction: stableOnCancelInteraction,
|
||||
issueStatus,
|
||||
successfulRunHandoff,
|
||||
}),
|
||||
[
|
||||
feedbackDataSharingPreference,
|
||||
@@ -3617,6 +4156,8 @@ export function IssueChatThread({
|
||||
stableOnRejectInteraction,
|
||||
stableOnSubmitInteractionAnswers,
|
||||
stableOnCancelInteraction,
|
||||
issueStatus,
|
||||
successfulRunHandoff,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3692,14 +4233,27 @@ export function IssueChatThread({
|
||||
stoppingRunId={stoppingRunId}
|
||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
{showComposer ? (
|
||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||
<IssueAssignedBacklogNotice
|
||||
issueStatus={issueStatus ?? ""}
|
||||
assigneeAgent={assignedAgent}
|
||||
assigneeUserId={assigneeUserId}
|
||||
onResume={onResumeFromBacklog}
|
||||
resuming={resumeFromBacklogPending}
|
||||
/>
|
||||
<IssueBlockedNotice
|
||||
issueStatus={issueStatus}
|
||||
blockers={unresolvedBlockers}
|
||||
blockerAttention={blockerAttention}
|
||||
successfulRunHandoff={successfulRunHandoff}
|
||||
agentName={
|
||||
successfulRunHandoff?.assigneeAgentId
|
||||
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<IssueAssigneePausedNotice agent={assignedAgent} />
|
||||
</div>
|
||||
@@ -3736,6 +4290,8 @@ export function IssueChatThread({
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
composerHint={composerHint}
|
||||
issueStatus={issueStatus}
|
||||
issueWorkMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread } from "./IssueChatThread";
|
||||
import type { IssueChatComment } from "../lib/issue-chat-messages";
|
||||
import type { Agent, SuccessfulRunHandoffState } from "@paperclipai/shared";
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
useAui: () => ({ thread: () => ({ append: async () => undefined }) }),
|
||||
}));
|
||||
|
||||
vi.mock("./transcript/useLiveRunTranscripts", () => ({
|
||||
useLiveRunTranscripts: () => ({
|
||||
transcriptByRun: new Map(),
|
||||
hasOutputForRun: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
MarkdownEditor: () => <textarea aria-label="Issue chat editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({ InlineEntitySelector: () => null }));
|
||||
vi.mock("./Identity", () => ({ Identity: ({ name }: { name: string }) => <span>{name}</span> }));
|
||||
vi.mock("./OutputFeedbackButtons", () => ({ OutputFeedbackButtons: () => null }));
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("./AgentIconPicker", () => ({ AgentIcon: () => null }));
|
||||
vi.mock("./StatusBadge", () => ({ StatusBadge: ({ status }: { status: string }) => <span>{status}</span> }));
|
||||
vi.mock("./IssueLinkQuicklook", () => ({
|
||||
IssueLinkQuicklook: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
|
||||
usePaperclipIssueRuntime: () => ({}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
window.scrollTo = vi.fn();
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function renderThread(
|
||||
comments: IssueChatComment[],
|
||||
options: {
|
||||
agentMap?: Map<string, Agent>;
|
||||
issueStatus?: string;
|
||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
} = {},
|
||||
) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={comments}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
agentMap={options.agentMap}
|
||||
issueStatus={options.issueStatus}
|
||||
successfulRunHandoff={options.successfulRunHandoff}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const baseTimestamps = {
|
||||
createdAt: new Date("2026-05-04T16:32:00.000Z"),
|
||||
updatedAt: new Date("2026-05-04T16:32:00.000Z"),
|
||||
};
|
||||
|
||||
describe("IssueChatThread system notice routing", () => {
|
||||
it("renders authorType=system comments as a SystemNotice rather than a user bubble", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-system",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
body: "Paperclip needs a disposition before this issue can continue.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: "Missing issue disposition",
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [
|
||||
{
|
||||
title: "Required action",
|
||||
rows: [
|
||||
{ type: "issue_link", label: "Source issue", issueId: "i1", identifier: "PAP-3440", title: "Recovery" },
|
||||
{ type: "key_value", label: "Status before", value: "in_progress" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
const row = container.querySelector('[data-message-role="system"]');
|
||||
expect(row).not.toBeNull();
|
||||
const status = row?.querySelector('[role="status"]');
|
||||
expect(status?.getAttribute("aria-label")).toBe("Missing issue disposition");
|
||||
expect(container.textContent).toContain("Paperclip needs a disposition");
|
||||
// collapsed by default — metadata identifier should not be visible
|
||||
expect(container.textContent).not.toContain("PAP-3440");
|
||||
const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement | null;
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.querySelectorAll('[data-message-role="user"]').length).toBe(0);
|
||||
});
|
||||
|
||||
it("expands metadata when detailsDefaultOpen is true", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-system-open",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
body: "Recovery escalated.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "danger",
|
||||
title: null,
|
||||
detailsDefaultOpen: true,
|
||||
},
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [
|
||||
{
|
||||
rows: [
|
||||
{ type: "agent_link", label: "Owner", agentId: "agent-cto", name: "CTO" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status?.getAttribute("aria-label")).toBe("System alert");
|
||||
expect(container.textContent).toContain("CTO");
|
||||
const toggle = container.querySelector("button[aria-expanded]");
|
||||
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("falls back to legacy user bubble + handoff callout for old text-only comments", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-legacy",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "## Successful run missing issue disposition\n\nFix this.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
const userRow = container.querySelector('[data-message-role="user"]');
|
||||
expect(userRow).not.toBeNull();
|
||||
expect(container.textContent).toContain("Successful run missing issue disposition");
|
||||
});
|
||||
|
||||
it("keeps regular user comments rendering as user bubbles", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-user",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "Standard user message.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
expect(container.querySelector('[data-message-role="user"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Standard user message.");
|
||||
});
|
||||
|
||||
it("keeps agent-authored comments rendering as assistant bubbles even with system_notice presentation absent", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-agent",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: "Agent reply",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("labels system notice source as the originating run agent name when runAgentId is available", () => {
|
||||
const codexAgent = {
|
||||
id: "agent-codex",
|
||||
name: "CodexCoder",
|
||||
} as unknown as Agent;
|
||||
const agentMap = new Map<string, Agent>([[codexAgent.id, codexAgent]]);
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-system-runagent",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
runId: "run-issue-chat-01",
|
||||
runAgentId: "agent-codex",
|
||||
body: "Paperclip needs a disposition before this issue can continue.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: "Missing issue disposition",
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
metadata: null,
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment], { agentMap });
|
||||
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).not.toBeNull();
|
||||
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
|
||||
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-codex/runs/run-issue-chat-01");
|
||||
expect(sourceLink?.textContent).toBe("CodexCoder");
|
||||
expect(sourceLink?.textContent).not.toBe("You");
|
||||
});
|
||||
|
||||
it("shows copy-link feedback on the link button only", async () => {
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-copy-link",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
body: "System recovery completed.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "success",
|
||||
title: null,
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
metadata: null,
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
const copyLink = container.querySelector('button[aria-label="Copy link to system notice"]') as HTMLButtonElement;
|
||||
const copyText = container.querySelector('button[aria-label="Copy system notice"]') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
copyLink.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("#comment-comment-copy-link"));
|
||||
expect(copyLink.querySelector(".lucide-check")).not.toBeNull();
|
||||
expect(copyText.querySelector(".lucide-check")).toBeNull();
|
||||
});
|
||||
|
||||
it("labels system notice source as Paperclip when no run agent can be resolved", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-system-no-author",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
runId: null,
|
||||
runAgentId: null,
|
||||
body: "System recovery completed.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "info",
|
||||
title: null,
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
metadata: null,
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).not.toBeNull();
|
||||
expect(status?.textContent).toContain("Paperclip");
|
||||
expect(status?.textContent).not.toContain("You");
|
||||
});
|
||||
|
||||
it("falls back to Paperclip in the system notice header when run agent is unknown to agentMap", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-system-unknown-agent",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
runId: "run-xyz",
|
||||
runAgentId: "agent-unknown",
|
||||
body: "Disposition required.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: null,
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
metadata: null,
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
const status = container.querySelector('[role="status"]');
|
||||
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
|
||||
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-unknown/runs/run-xyz");
|
||||
expect(sourceLink?.textContent).toBe("Paperclip");
|
||||
});
|
||||
|
||||
it("keeps agent-authored comments as assistant bubbles even when presentation requests system_notice", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-agent-system",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: "Reassigned to ClaudeFixer.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "neutral",
|
||||
title: null,
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
metadata: null,
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment]);
|
||||
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("folds stale successful-run disposition warnings into the activity log disclosure style", () => {
|
||||
const comment: IssueChatComment = {
|
||||
id: "comment-stale-disposition-warning",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
runId: "run-stale",
|
||||
runAgentId: "agent-codex",
|
||||
body: "Paperclip needs a disposition before this issue can continue.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: "Missing issue disposition",
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
metadata: {
|
||||
version: 1,
|
||||
sourceRunId: "run-stale",
|
||||
sections: [
|
||||
{
|
||||
title: "Run evidence",
|
||||
rows: [
|
||||
{ type: "run_link", label: "Completed run", runId: "run-stale", title: "succeeded" },
|
||||
{ type: "key_value", label: "Normalized cause", value: "successful_run_missing_state" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...baseTimestamps,
|
||||
};
|
||||
|
||||
renderThread([comment], {
|
||||
issueStatus: "done",
|
||||
successfulRunHandoff: {
|
||||
state: "resolved",
|
||||
required: false,
|
||||
sourceRunId: "run-stale",
|
||||
correctiveRunId: "run-corrective",
|
||||
assigneeAgentId: "agent-codex",
|
||||
detectedProgressSummary: null,
|
||||
createdAt: new Date("2026-05-04T17:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const row = container.querySelector('[data-testid="stale-disposition-warning"]');
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.querySelector('span[aria-hidden="true"]')?.className).toContain("size-6");
|
||||
const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement;
|
||||
expect(toggle.className).toContain("w-full");
|
||||
expect(toggle.className).toContain("py-0.5");
|
||||
expect(row?.querySelector('[role="status"]')).toBeNull();
|
||||
expect(row?.querySelector(".lucide-triangle-alert")).toBeNull();
|
||||
expect(row?.querySelector(".lucide-chevron-down")).not.toBeNull();
|
||||
expect(row?.querySelector('[data-testid="stale-disposition-warning-time"]')?.parentElement?.className).toContain("ml-auto");
|
||||
expect(row?.textContent).toContain("Stale disposition warning");
|
||||
expect(row?.textContent).not.toContain("This disposition warning is stale because the issue now has a newer disposition.");
|
||||
expect(row?.textContent).not.toContain("Paperclip needs a disposition before this issue can continue.");
|
||||
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
const detailsId = toggle.getAttribute("aria-controls");
|
||||
expect(detailsId).toBeTruthy();
|
||||
const details = detailsId ? container.ownerDocument.getElementById(detailsId) : null;
|
||||
expect(details).not.toBeNull();
|
||||
expect(details?.textContent).toContain("run-stale");
|
||||
expect(details).toHaveProperty("hidden", true);
|
||||
act(() => {
|
||||
toggle.click();
|
||||
});
|
||||
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(details).toHaveProperty("hidden", false);
|
||||
expect(container.textContent).toContain("run-stale");
|
||||
});
|
||||
});
|
||||
@@ -215,6 +215,7 @@ function createIssue(): Issue {
|
||||
title: "Plan rendering",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueMonitorActivityCard } from "./IssueMonitorActivityCard";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Watch deploy",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
issueNumber: 1,
|
||||
identifier: "PAP-1",
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment health",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
},
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: 0,
|
||||
notes: "Check deployment health",
|
||||
scheduledBy: "board",
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
},
|
||||
},
|
||||
monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"),
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorAttemptCount: 0,
|
||||
monitorNotes: "Check deployment health",
|
||||
monitorScheduledBy: "board",
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-04-11T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T10:00:00.000Z"),
|
||||
...overrides,
|
||||
workMode: overrides.workMode ?? "standard",
|
||||
};
|
||||
}
|
||||
|
||||
describe("IssueMonitorActivityCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-11T12:00:00.000Z"));
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders the scheduled monitor details and check-now action", () => {
|
||||
const onCheckNow = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueMonitorActivityCard issue={createIssue()} onCheckNow={onCheckNow} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Monitor scheduled");
|
||||
expect(container.textContent).toContain("Next check");
|
||||
expect(container.textContent).toContain("in 30m");
|
||||
expect(container.textContent).toContain("Check deployment health");
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
|
||||
candidate.textContent?.includes("Check now"),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onCheckNow).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not render external references from monitor metadata", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueMonitorActivityCard
|
||||
issue={createIssue({
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment health",
|
||||
scheduledBy: "board",
|
||||
serviceName: "Deploy provider",
|
||||
externalRef: "https://provider.example/deploy/123?token=secret",
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Deploy provider");
|
||||
expect(container.textContent).not.toContain("provider.example");
|
||||
expect(container.textContent).not.toContain("token=secret");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders without throwing when monitorNextCheckAt arrives as an ISO string", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueMonitorActivityCard
|
||||
issue={createIssue({
|
||||
monitorNextCheckAt: "2026-04-11T12:30:00.000Z" as unknown as Date,
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
},
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: null,
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Monitor scheduled");
|
||||
expect(container.textContent).toContain("Next check");
|
||||
expect(container.textContent).toContain("in 30m");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders nothing when the issue has no scheduled monitor", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueMonitorActivityCard
|
||||
issue={createIssue({
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
},
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: null,
|
||||
},
|
||||
monitorNextCheckAt: null,
|
||||
monitorNotes: null,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatMonitorOffset } from "@/lib/issue-monitor";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
|
||||
function resolveScheduledMonitor(issue: Issue) {
|
||||
const nextCheckAt =
|
||||
issue.monitorNextCheckAt ??
|
||||
issue.executionPolicy?.monitor?.nextCheckAt ??
|
||||
issue.executionState?.monitor?.nextCheckAt ??
|
||||
null;
|
||||
if (!nextCheckAt) return null;
|
||||
|
||||
return {
|
||||
nextCheckAt,
|
||||
notes: issue.executionPolicy?.monitor?.notes ?? issue.monitorNotes ?? issue.executionState?.monitor?.notes ?? null,
|
||||
attemptCount: issue.monitorAttemptCount ?? issue.executionState?.monitor?.attemptCount ?? 0,
|
||||
serviceName: issue.executionPolicy?.monitor?.serviceName ?? issue.executionState?.monitor?.serviceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
interface IssueMonitorActivityCardProps {
|
||||
issue: Issue;
|
||||
onCheckNow?: (() => void) | null;
|
||||
checkingNow?: boolean;
|
||||
}
|
||||
|
||||
export function IssueMonitorActivityCard({
|
||||
issue,
|
||||
onCheckNow = null,
|
||||
checkingNow = false,
|
||||
}: IssueMonitorActivityCardProps) {
|
||||
const monitor = resolveScheduledMonitor(issue);
|
||||
if (!monitor) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Monitor scheduled</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Next check {formatDateTime(monitor.nextCheckAt)} ({formatMonitorOffset(monitor.nextCheckAt)})
|
||||
</div>
|
||||
{monitor.notes ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{monitor.notes}</div>
|
||||
) : null}
|
||||
{monitor.serviceName ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{monitor.serviceName}
|
||||
</div>
|
||||
) : null}
|
||||
{monitor.attemptCount > 0 ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">Attempt {monitor.attemptCount}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{onCheckNow ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 shadow-none"
|
||||
onClick={onCheckNow}
|
||||
disabled={checkingNow}
|
||||
>
|
||||
{checkingNow ? "Checking..." : "Check now"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { IssueProperties } from "./IssueProperties";
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
adapterModels: vi.fn(),
|
||||
adapterModelProfiles: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProjectsApi = vi.hoisted(() => ({
|
||||
@@ -34,10 +36,6 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
@@ -60,8 +58,8 @@ vi.mock("../api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
vi.mock("../context/ToastContext", () => ({
|
||||
useToastActions: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useProjectOrder", () => ({
|
||||
@@ -162,6 +160,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
|
||||
...overrides,
|
||||
workMode: overrides.workMode ?? "standard",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,6 +351,8 @@ describe("IssueProperties", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
||||
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
@@ -361,7 +362,6 @@ describe("IssueProperties", () => {
|
||||
color: "#6366f1",
|
||||
}));
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -476,6 +476,59 @@ describe("IssueProperties", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "issue-2",
|
||||
identifier: "PAP-2",
|
||||
title: "Existing blocker",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
{
|
||||
id: "issue-4",
|
||||
identifier: "PAP-4",
|
||||
title: "Keep blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
|
||||
expect(removeButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this issue.");
|
||||
const confirmButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Remove blocker"));
|
||||
expect(confirmButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||
const serviceUrl = "http://127.0.0.1:62475";
|
||||
@@ -505,9 +558,27 @@ describe("IssueProperties", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a workspace tasks link for non-default workspaces when isolated workspaces are enabled", async () => {
|
||||
it("shows full date and time for issue metadata timestamps", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
createdAt: new Date(2026, 3, 6, 12, 34),
|
||||
startedAt: new Date(2026, 3, 6, 12, 35),
|
||||
completedAt: new Date(2026, 3, 6, 12, 36),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toMatch(/CreatedApr 6, 2026, \d{1,2}:34 (AM|PM)/);
|
||||
expect(container.textContent).toMatch(/StartedApr 6, 2026, \d{1,2}:35 (AM|PM)/);
|
||||
expect(container.textContent).toMatch(/CompletedApr 6, 2026, \d{1,2}:36 (AM|PM)/);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows only the workspace detail link for non-default workspaces", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
projectId: "project-1",
|
||||
@@ -523,14 +594,10 @@ describe("IssueProperties", () => {
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const tasksLink = Array.from(container.querySelectorAll("a")).find(
|
||||
(link) => link.textContent?.includes("View workspace tasks"),
|
||||
);
|
||||
const workspaceLink = Array.from(container.querySelectorAll("a")).find(
|
||||
(link) => link.textContent?.trim() === "View workspace",
|
||||
);
|
||||
expect(tasksLink).not.toBeUndefined();
|
||||
expect(tasksLink?.getAttribute("href")).toBe("/issues?workspace=workspace-1");
|
||||
expect(container.textContent).not.toContain("View workspace tasks");
|
||||
expect(workspaceLink).not.toBeUndefined();
|
||||
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
|
||||
|
||||
@@ -733,6 +800,132 @@ describe("IssueProperties", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("hides model options when the issue uses the assignee default", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "Senior Product Engineer",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
icon: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeAdapterOverrides: null,
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).not.toContain("Model lane");
|
||||
expect(container.textContent).not.toContain("Codex options");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("edits existing custom assignee model options from the properties pane", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "Senior Product Engineer",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
icon: null,
|
||||
},
|
||||
]);
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([
|
||||
{ id: "gpt-5.5", label: "GPT-5.5" },
|
||||
{ id: "gpt-5.4", label: "GPT-5.4" },
|
||||
]);
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeAdapterOverrides: {
|
||||
adapterConfig: {
|
||||
model: "gpt-5.4",
|
||||
modelReasoningEffort: "high",
|
||||
},
|
||||
},
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Custom · gpt-5.4 · high");
|
||||
expect(container.textContent).toContain("Model lane");
|
||||
|
||||
const modelButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("GPT-5.5"));
|
||||
expect(modelButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({
|
||||
assigneeAdapterOverrides: {
|
||||
adapterConfig: {
|
||||
model: "gpt-5.5",
|
||||
modelReasoningEffort: "high",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("clears existing assignee adapter overrides from the properties pane", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "Senior Product Engineer",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
icon: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeAdapterOverrides: {
|
||||
adapterConfig: {
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const clearButton = container.querySelector('button[aria-label="Clear adapter options"]');
|
||||
expect(clearButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ assigneeAdapterOverrides: null });
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows a checkmark on selected labels in the picker", async () => {
|
||||
mockIssuesApi.listLabels.mockResolvedValue([
|
||||
createLabel(),
|
||||
@@ -969,4 +1162,84 @@ describe("IssueProperties", () => {
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders monitor controls and clears an existing monitor", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-1",
|
||||
executionPolicy: createExecutionPolicy({
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
}),
|
||||
executionState: createExecutionState({
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: 0,
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Monitor");
|
||||
expect(container.textContent).toContain("Next check");
|
||||
expect(container.querySelector('input[type="datetime-local"]')).toBeNull();
|
||||
expect(container.querySelector('input[placeholder="What should the agent re-check?"]')).toBeNull();
|
||||
|
||||
const monitorTrigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Next check"));
|
||||
expect(monitorTrigger).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
monitorTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const inputs = Array.from(container.querySelectorAll("input"));
|
||||
const datetimeInput = inputs.find((input) => input.getAttribute("type") === "datetime-local");
|
||||
const textInput = inputs.find((input) => input.getAttribute("placeholder") === "What should the agent re-check?");
|
||||
const clearButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Clear"));
|
||||
|
||||
expect(datetimeInput).toBeTruthy();
|
||||
expect(textInput).toBeTruthy();
|
||||
expect(clearButton).toBeTruthy();
|
||||
expect(datetimeInput!.value).toBeTruthy();
|
||||
expect(textInput!.value).toBe("Check deployment");
|
||||
|
||||
act(() => {
|
||||
clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
},
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,16 +3,16 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Issue, IssueLabel, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AdapterModel } from "../api/agents";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { resolveIssueFilterWorkspaceId } from "../lib/issue-filters";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import {
|
||||
getRecentAssigneeIds,
|
||||
@@ -25,16 +25,33 @@ import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"
|
||||
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
|
||||
import { formatMonitorOffset } from "../lib/issue-monitor";
|
||||
import { formatRetryReason } from "../lib/runRetryState";
|
||||
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
|
||||
import { RetryErrorBand } from "./IssueScheduledRetryCard";
|
||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import { IssueReferencePill } from "./IssueReferencePill";
|
||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||
import { formatDate, formatDateTime, cn, projectUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink } from "lucide-react";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
|
||||
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -112,10 +129,12 @@ function runningRuntimeServiceWithUrl(
|
||||
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
|
||||
}
|
||||
|
||||
function issuesWorkspaceFilterHref(workspaceId: string) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("workspace", workspaceId);
|
||||
return `/issues?${params.toString()}`;
|
||||
function toDateTimeLocalValue(value: string | null | undefined) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
@@ -135,6 +154,163 @@ function PropertyRow({ label, children }: { label: string; children: React.React
|
||||
);
|
||||
}
|
||||
|
||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||
claude_local: [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
],
|
||||
codex_local: [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "minimal", label: "Minimal" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "X-High" },
|
||||
],
|
||||
opencode_local: [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "minimal", label: "Minimal" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "X-High" },
|
||||
{ value: "max", label: "Max" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function compactRecord(record: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function thinkingEffortOptionsFor(adapterType: string | null | undefined) {
|
||||
if (adapterType === "codex_local") return ISSUE_THINKING_EFFORT_OPTIONS.codex_local;
|
||||
if (adapterType === "opencode_local") return ISSUE_THINKING_EFFORT_OPTIONS.opencode_local;
|
||||
return ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||
}
|
||||
|
||||
function thinkingEffortKeyFor(adapterType: string | null | undefined) {
|
||||
if (adapterType === "codex_local") return "modelReasoningEffort";
|
||||
if (adapterType === "opencode_local") return "variant";
|
||||
return "effort";
|
||||
}
|
||||
|
||||
function thinkingEffortValueFor(adapterType: string | null | undefined, adapterConfig: Record<string, unknown>) {
|
||||
if (adapterType === "codex_local") {
|
||||
return String(adapterConfig.modelReasoningEffort ?? adapterConfig.reasoningEffort ?? adapterConfig.effort ?? "");
|
||||
}
|
||||
if (adapterType === "opencode_local") {
|
||||
return String(adapterConfig.variant ?? "");
|
||||
}
|
||||
return String(adapterConfig.effort ?? "");
|
||||
}
|
||||
|
||||
function overrideLane(overrides: Issue["assigneeAdapterOverrides"]): IssueModelLane {
|
||||
if (overrides?.modelProfile === "cheap") return "cheap";
|
||||
if (overrides?.adapterConfig) return "custom";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
function sortAdapterModels(models: AdapterModel[]) {
|
||||
return [...models].sort((a, b) => {
|
||||
const providerA = extractProviderIdWithFallback(a.id);
|
||||
const providerB = extractProviderIdWithFallback(b.id);
|
||||
const byProvider = providerA.localeCompare(providerB);
|
||||
if (byProvider !== 0) return byProvider;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
function RemovableIssueReferencePill({
|
||||
issue,
|
||||
onRemove,
|
||||
}: {
|
||||
issue: NonNullable<Issue["blockedBy"]>[number];
|
||||
onRemove: (issueId: string) => void;
|
||||
}) {
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const issueLabel = issue.identifier ?? issue.title;
|
||||
const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{issueLabel}</span>
|
||||
</>
|
||||
);
|
||||
const removeLabel = `Remove ${issueLabel} as blocker`;
|
||||
const handleRemove = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsConfirmOpen(true);
|
||||
};
|
||||
const confirmRemove = () => {
|
||||
onRemove(issue.id);
|
||||
setIsConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-mention-kind="issue"
|
||||
className={cn(
|
||||
"paperclip-mention-chip paperclip-mention-chip--issue group",
|
||||
"inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-1 pr-2 text-xs",
|
||||
)}
|
||||
title={issue.title}
|
||||
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{issue.identifier ? (
|
||||
<Link
|
||||
to={`/issues/${issueLabel}`}
|
||||
className="inline-flex min-w-0 items-center gap-1 no-underline hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">{content}</span>
|
||||
)}
|
||||
</span>
|
||||
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove blocker?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove {confirmLabel} as a blocker for this issue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant="destructive" onClick={confirmRemove}>
|
||||
Remove blocker
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
|
||||
function PropertyPicker({
|
||||
inline,
|
||||
@@ -219,10 +395,16 @@ export function IssueProperties({
|
||||
const [reviewerSearch, setReviewerSearch] = useState("");
|
||||
const [approversOpen, setApproversOpen] = useState(false);
|
||||
const [approverSearch, setApproverSearch] = useState("");
|
||||
const [monitorOpen, setMonitorOpen] = useState(false);
|
||||
const [scheduledRetryOpen, setScheduledRetryOpen] = useState(false);
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
const [labelSearch, setLabelSearch] = useState("");
|
||||
const [newLabelName, setNewLabelName] = useState("");
|
||||
const [newLabelColor, setNewLabelColor] = useState("#6366f1");
|
||||
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
|
||||
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
|
||||
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
@@ -240,12 +422,6 @@ export function IssueProperties({
|
||||
queryFn: () => accessApi.listUserDirectory(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId!),
|
||||
queryFn: () => projectsApi.list(companyId!),
|
||||
@@ -313,16 +489,10 @@ export function IssueProperties({
|
||||
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
||||
: null;
|
||||
const issueProject = issue.project ?? currentProject;
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const issueUsesMainWorkspace = useMemo(
|
||||
() => isMainIssueWorkspace({ issue, project: issueProject }),
|
||||
[issue, issueProject],
|
||||
);
|
||||
const workspaceFilterId = useMemo(() => {
|
||||
if (!isolatedWorkspacesEnabled) return null;
|
||||
if (issueUsesMainWorkspace) return null;
|
||||
return resolveIssueFilterWorkspaceId(issue);
|
||||
}, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]);
|
||||
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
|
||||
const liveWorkspaceService = useMemo(() => {
|
||||
if (issueUsesMainWorkspace) return null;
|
||||
@@ -381,6 +551,219 @@ export function IssueProperties({
|
||||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
: null;
|
||||
const assigneeAdapterType = assignee?.adapterType ?? null;
|
||||
const assigneeAdapterOverrides = issue.assigneeAdapterOverrides ?? null;
|
||||
const showAssigneeAdapterOptions = assigneeAdapterOverrides !== null;
|
||||
const supportsAssigneeOverrides = Boolean(
|
||||
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
||||
);
|
||||
const assigneeSupportsCheapLane = Boolean(
|
||||
supportsAssigneeOverrides
|
||||
&& (assigneeAdapterType === "claude_local"
|
||||
|| assigneeAdapterType === "codex_local"
|
||||
|| assigneeAdapterType === "opencode_local"),
|
||||
);
|
||||
const assigneeOverrideLane = overrideLane(assigneeAdapterOverrides);
|
||||
const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig);
|
||||
const assigneeOverrideModel =
|
||||
typeof assigneeOverrideAdapterConfig.model === "string" ? assigneeOverrideAdapterConfig.model : "";
|
||||
const assigneeOverrideThinkingEffort = thinkingEffortValueFor(
|
||||
assigneeAdapterType,
|
||||
assigneeOverrideAdapterConfig,
|
||||
);
|
||||
const assigneeOverrideChrome = assigneeAdapterType === "claude_local"
|
||||
&& assigneeOverrideAdapterConfig.chrome === true;
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey:
|
||||
companyId && assigneeAdapterType
|
||||
? queryKeys.agents.adapterModels(companyId, assigneeAdapterType)
|
||||
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
|
||||
queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!),
|
||||
enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides,
|
||||
});
|
||||
const { data: assigneeCheapProfiles } = useQuery({
|
||||
queryKey: companyId && assigneeAdapterType
|
||||
? queryKeys.agents.adapterModelProfiles(companyId, assigneeAdapterType)
|
||||
: ["agents", "none", "adapter-model-profiles", assigneeAdapterType ?? "none"],
|
||||
queryFn: () => agentsApi.adapterModelProfiles(companyId!, assigneeAdapterType!),
|
||||
enabled: Boolean(companyId) && showAssigneeAdapterOptions && assigneeSupportsCheapLane,
|
||||
});
|
||||
const assigneeCheapProfile = useMemo(
|
||||
() => (assigneeCheapProfiles ?? []).find((profile) => profile.key === "cheap") ?? null,
|
||||
[assigneeCheapProfiles],
|
||||
);
|
||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(() => {
|
||||
const models = sortAdapterModels(assigneeAdapterModels ?? []);
|
||||
const options = models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`,
|
||||
}));
|
||||
if (assigneeOverrideModel && !options.some((option) => option.id === assigneeOverrideModel)) {
|
||||
options.unshift({
|
||||
id: assigneeOverrideModel,
|
||||
label: assigneeOverrideModel,
|
||||
searchText: assigneeOverrideModel,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [assigneeAdapterModels, assigneeOverrideModel]);
|
||||
const updateAssigneeAdapterOverrides = (next: Issue["assigneeAdapterOverrides"]) => {
|
||||
onUpdate({ assigneeAdapterOverrides: next });
|
||||
};
|
||||
const buildAssigneeOverrideWithConfig = (adapterConfig: Record<string, unknown>) => {
|
||||
const nextConfig = compactRecord(adapterConfig);
|
||||
const next = compactRecord({
|
||||
useProjectWorkspace: assigneeAdapterOverrides?.useProjectWorkspace,
|
||||
...(Object.keys(nextConfig).length > 0 ? { adapterConfig: nextConfig } : {}),
|
||||
});
|
||||
return Object.keys(next).length > 0 ? next : null;
|
||||
};
|
||||
const updateAssigneeOverrideConfig = (patch: Record<string, unknown>) => {
|
||||
updateAssigneeAdapterOverrides(
|
||||
buildAssigneeOverrideWithConfig({
|
||||
...assigneeOverrideAdapterConfig,
|
||||
...patch,
|
||||
}),
|
||||
);
|
||||
};
|
||||
const updateAssigneeOverrideThinkingEffort = (nextValue: string) => {
|
||||
const nextConfig = { ...assigneeOverrideAdapterConfig };
|
||||
delete nextConfig.modelReasoningEffort;
|
||||
delete nextConfig.reasoningEffort;
|
||||
delete nextConfig.effort;
|
||||
delete nextConfig.variant;
|
||||
if (nextValue) {
|
||||
nextConfig[thinkingEffortKeyFor(assigneeAdapterType)] = nextValue;
|
||||
}
|
||||
updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(nextConfig));
|
||||
};
|
||||
const setAssigneeOverrideLane = (lane: IssueModelLane) => {
|
||||
if (lane === "primary") {
|
||||
updateAssigneeAdapterOverrides(null);
|
||||
return;
|
||||
}
|
||||
if (lane === "cheap") {
|
||||
updateAssigneeAdapterOverrides(
|
||||
compactRecord({
|
||||
useProjectWorkspace: assigneeAdapterOverrides?.useProjectWorkspace,
|
||||
modelProfile: "cheap",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateAssigneeAdapterOverrides(buildAssigneeOverrideWithConfig(assigneeOverrideAdapterConfig) ?? { adapterConfig: {} });
|
||||
};
|
||||
const assigneeOptionsTrigger = (() => {
|
||||
if (assigneeOverrideLane === "cheap") {
|
||||
return <span className="text-sm">Cheap model</span>;
|
||||
}
|
||||
if (assigneeOverrideLane === "custom") {
|
||||
const details = [
|
||||
assigneeOverrideModel,
|
||||
assigneeOverrideThinkingEffort,
|
||||
assigneeOverrideChrome ? "Chrome" : "",
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<span className="min-w-0 text-sm break-words">
|
||||
Custom{details.length > 0 ? ` · ${details.join(" · ")}` : " adapter options"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-sm text-muted-foreground">Primary model</span>;
|
||||
})();
|
||||
const assigneeOptionsContent = supportsAssigneeOverrides ? (
|
||||
<div className="w-full space-y-3 p-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Model lane</div>
|
||||
<div className="flex w-full overflow-hidden rounded-md border border-border" role="radiogroup" aria-label="Model lane">
|
||||
{(["primary", ...(assigneeSupportsCheapLane ? (["cheap"] as const) : ([] as const)), "custom"] as const).map((lane) => (
|
||||
<button
|
||||
key={lane}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={assigneeOverrideLane === lane}
|
||||
className={cn(
|
||||
"flex-1 px-2 py-1 text-xs capitalize transition-colors hover:bg-accent/40",
|
||||
assigneeOverrideLane === lane && "bg-accent text-foreground",
|
||||
)}
|
||||
onClick={() => setAssigneeOverrideLane(lane)}
|
||||
>
|
||||
{lane === "primary" ? "Primary" : lane === "cheap" ? "Cheap" : "Custom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{assigneeOverrideLane === "cheap" ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Sends <code>modelProfile: "cheap"</code>{" "}
|
||||
{assigneeCheapProfile?.adapterConfig && typeof (assigneeCheapProfile.adapterConfig as Record<string, unknown>).model === "string"
|
||||
? <>· adapter default <code>{String((assigneeCheapProfile.adapterConfig as Record<string, unknown>).model)}</code></>
|
||||
: assigneeCheapProfile
|
||||
? <>· uses the agent's configured cheap profile</>
|
||||
: <>· falls back to the primary model if no cheap profile is configured</>}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{assigneeOverrideLane === "custom" ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Model</div>
|
||||
<InlineEntitySelector
|
||||
value={assigneeOverrideModel}
|
||||
options={modelOverrideOptions}
|
||||
placeholder="Default model"
|
||||
disablePortal
|
||||
noneLabel="Default model"
|
||||
searchPlaceholder="Search models..."
|
||||
emptyMessage="No models found."
|
||||
onChange={(model) => updateAssigneeOverrideConfig({ model: model || undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Thinking effort</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{thinkingEffortOptionsFor(assigneeAdapterType).map((option) => (
|
||||
<button
|
||||
key={option.value || "default"}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-md text-xs border border-border hover:bg-accent/50 transition-colors",
|
||||
assigneeOverrideThinkingEffort === option.value && "bg-accent",
|
||||
)}
|
||||
onClick={() => updateAssigneeOverrideThinkingEffort(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{assigneeAdapterType === "claude_local" ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
||||
<ToggleSwitch
|
||||
checked={assigneeOverrideChrome}
|
||||
onCheckedChange={(next) => updateAssigneeOverrideConfig({ chrome: next ? true : undefined })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-2 p-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignee
|
||||
? "This assignee's adapter does not expose editable issue overrides."
|
||||
: "Select a compatible agent assignee to edit these overrides."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={() => updateAssigneeAdapterOverrides(null)}
|
||||
>
|
||||
Clear adapter options
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
||||
@@ -459,6 +842,308 @@ export function IssueProperties({
|
||||
}
|
||||
return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`;
|
||||
})();
|
||||
useEffect(() => {
|
||||
setMonitorAtInput(toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
|
||||
setMonitorNotesInput(issue.executionPolicy?.monitor?.notes ?? "");
|
||||
setMonitorServiceInput(issue.executionPolicy?.monitor?.serviceName ?? "");
|
||||
}, [
|
||||
issue.executionPolicy?.monitor?.nextCheckAt,
|
||||
issue.executionPolicy?.monitor?.notes,
|
||||
issue.executionPolicy?.monitor?.serviceName,
|
||||
]);
|
||||
|
||||
const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T
|
||||
? T extends { monitor?: infer M | null } | null | undefined
|
||||
? M | null
|
||||
: never
|
||||
: never) => {
|
||||
const basePolicy = buildExecutionPolicy({
|
||||
existingPolicy: issue.executionPolicy ?? null,
|
||||
reviewerValues,
|
||||
approverValues,
|
||||
});
|
||||
if (!basePolicy && !nextMonitor) {
|
||||
onUpdate({ executionPolicy: null });
|
||||
return;
|
||||
}
|
||||
onUpdate({
|
||||
executionPolicy: {
|
||||
mode: basePolicy?.mode ?? issue.executionPolicy?.mode ?? "normal",
|
||||
commentRequired: true,
|
||||
stages: basePolicy?.stages ?? [],
|
||||
...(nextMonitor ? { monitor: nextMonitor } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
const saveMonitor = () => {
|
||||
if (!monitorAtInput) return;
|
||||
const nextCheckAt = new Date(monitorAtInput);
|
||||
if (Number.isNaN(nextCheckAt.getTime())) return;
|
||||
const serviceName = monitorServiceInput.trim() || null;
|
||||
updateMonitor({
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
notes: monitorNotesInput.trim() || null,
|
||||
scheduledBy: "board",
|
||||
kind: serviceName ? "external_service" : null,
|
||||
serviceName,
|
||||
externalRef: null,
|
||||
});
|
||||
setMonitorOpen(false);
|
||||
};
|
||||
const clearMonitor = () => {
|
||||
updateMonitor(null);
|
||||
setMonitorOpen(false);
|
||||
};
|
||||
const currentMonitorLabel = (() => {
|
||||
if (issue.executionPolicy?.monitor?.nextCheckAt) {
|
||||
return `Next check ${formatDate(new Date(issue.executionPolicy.monitor.nextCheckAt))}`;
|
||||
}
|
||||
if (issue.executionState?.monitor?.status === "cleared") {
|
||||
return "Cleared";
|
||||
}
|
||||
if (issue.monitorLastTriggeredAt) {
|
||||
return `Last triggered ${timeAgo(issue.monitorLastTriggeredAt)}`;
|
||||
}
|
||||
return "Not scheduled";
|
||||
})();
|
||||
const monitorNextCheckAt = issue.executionPolicy?.monitor?.nextCheckAt ?? null;
|
||||
const monitorTrigger = (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
{monitorNextCheckAt ? (
|
||||
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 text-sm break-words",
|
||||
monitorNextCheckAt ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
title={monitorNextCheckAt ? currentMonitorLabel : undefined}
|
||||
>
|
||||
{monitorNextCheckAt ? `Next check ${formatMonitorOffset(monitorNextCheckAt)}` : currentMonitorLabel}
|
||||
</span>
|
||||
{monitorNextCheckAt ? (
|
||||
<span className="text-xs text-muted-foreground" title={currentMonitorLabel}>
|
||||
{formatDate(new Date(monitorNextCheckAt))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
const monitorAttemptBadge = issue.monitorAttemptCount && issue.monitorAttemptCount > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Attempt {issue.monitorAttemptCount}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const scheduledRetry = issue.scheduledRetry ?? null;
|
||||
const retryNow = useRetryNowMutation(issue.id);
|
||||
const showScheduledRetryRow = scheduledRetry && scheduledRetry.status === "scheduled_retry";
|
||||
const scheduledRetryDueAtIso = scheduledRetry?.scheduledRetryAt
|
||||
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
|
||||
: null;
|
||||
const scheduledRetryRelative = scheduledRetryDueAtIso
|
||||
? formatMonitorOffset(scheduledRetryDueAtIso)
|
||||
: null;
|
||||
const scheduledRetryAbsolute = scheduledRetry?.scheduledRetryAt
|
||||
? formatDateTime(scheduledRetry.scheduledRetryAt)
|
||||
: null;
|
||||
const scheduledRetryShortDate = scheduledRetry?.scheduledRetryAt
|
||||
? formatDate(new Date(scheduledRetry.scheduledRetryAt))
|
||||
: null;
|
||||
const scheduledRetryReasonLabel = formatRetryReason(scheduledRetry?.scheduledRetryReason);
|
||||
const scheduledRetryAttempt =
|
||||
typeof scheduledRetry?.scheduledRetryAttempt === "number"
|
||||
&& Number.isFinite(scheduledRetry.scheduledRetryAttempt)
|
||||
&& scheduledRetry.scheduledRetryAttempt > 0
|
||||
? scheduledRetry.scheduledRetryAttempt
|
||||
: null;
|
||||
const scheduledRetryIsContinuation =
|
||||
scheduledRetry?.scheduledRetryReason === "max_turns_continuation";
|
||||
const scheduledRetryRelativeLabel = (() => {
|
||||
if (!scheduledRetryRelative) return "Pending schedule";
|
||||
const action = scheduledRetryIsContinuation ? "Continuation" : "Retry";
|
||||
if (scheduledRetryRelative === "now") return `${action} due now`;
|
||||
return `${action} ${scheduledRetryRelative}`;
|
||||
})();
|
||||
const scheduledRetryRetryNowSuccess = retryNow.isSuccess
|
||||
&& (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted");
|
||||
const scheduledRetryAttemptBadge = scheduledRetryAttempt !== null ? (
|
||||
<span className="text-xs text-muted-foreground">Attempt {scheduledRetryAttempt}</span>
|
||||
) : null;
|
||||
const scheduledRetryTrigger = (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-cyan-600 dark:text-cyan-400" aria-hidden="true" />
|
||||
<span
|
||||
className="min-w-0 text-sm break-words text-foreground"
|
||||
title={scheduledRetryAbsolute ?? undefined}
|
||||
>
|
||||
{scheduledRetryRelativeLabel}
|
||||
</span>
|
||||
{scheduledRetryShortDate ? (
|
||||
<span className="text-xs text-muted-foreground" title={scheduledRetryAbsolute ?? undefined}>
|
||||
{scheduledRetryShortDate}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
const scheduledRetryContent = scheduledRetry ? (
|
||||
<div className="flex w-full flex-col gap-2 p-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{scheduledRetryIsContinuation ? "Scheduled continuation" : "Scheduled retry"}
|
||||
</span>
|
||||
{scheduledRetryAttempt !== null ? (
|
||||
<span className="rounded-full border border-border bg-muted/30 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
Attempt {scheduledRetryAttempt}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="grid grid-cols-[6rem_1fr] gap-y-1">
|
||||
{scheduledRetryReasonLabel ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Reason</dt>
|
||||
<dd className="text-foreground">{scheduledRetryReasonLabel}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{scheduledRetryAbsolute ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Next attempt</dt>
|
||||
<dd className="text-foreground">
|
||||
{scheduledRetryAbsolute}
|
||||
{scheduledRetryRelative ? (
|
||||
<span className="ml-1 text-muted-foreground">· {scheduledRetryRelative}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{scheduledRetry.retryOfRunId ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Replaces run</dt>
|
||||
<dd className="text-foreground">
|
||||
<Link
|
||||
to={`/agents/${scheduledRetry.agentId}/runs/${scheduledRetry.retryOfRunId}`}
|
||||
className="font-mono text-foreground hover:underline"
|
||||
>
|
||||
{scheduledRetry.retryOfRunId.slice(0, 8)}
|
||||
</Link>
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{scheduledRetry.agentName ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Agent</dt>
|
||||
<dd className="text-foreground">
|
||||
<Link
|
||||
to={`/agents/${scheduledRetry.agentId}`}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{scheduledRetry.agentName}
|
||||
</Link>
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{scheduledRetry.error ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Last error</dt>
|
||||
<dd className="text-foreground break-words">{scheduledRetry.error}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<RetryErrorBand
|
||||
error={retryNow.lastError}
|
||||
onRetry={() => {
|
||||
retryNow.reset();
|
||||
retryNow.mutate();
|
||||
}}
|
||||
/>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => retryNow.mutate()}
|
||||
disabled={retryNow.isPending || scheduledRetryRetryNowSuccess}
|
||||
data-testid="issue-scheduled-retry-properties-retry-now"
|
||||
>
|
||||
{retryNow.isPending ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
Retrying…
|
||||
</span>
|
||||
) : scheduledRetryRetryNowSuccess ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Retry now
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-right text-xs text-muted-foreground">
|
||||
{retryNow.isPending
|
||||
? "Promoting scheduled retry"
|
||||
: scheduledRetryRetryNowSuccess
|
||||
? retryNow.data?.outcome === "already_promoted"
|
||||
? "Already promoted — run starting"
|
||||
: "Promoted — run starting"
|
||||
: scheduledRetryIsContinuation
|
||||
? "Pulls continuation forward immediately"
|
||||
: "Pulls retry forward immediately"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
const monitorContent = (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="rounded-md border border-border bg-transparent px-2 py-1 text-xs"
|
||||
value={monitorAtInput}
|
||||
onChange={(e) => setMonitorAtInput(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs"
|
||||
placeholder="What should the agent re-check?"
|
||||
value={monitorNotesInput}
|
||||
onChange={(e) => setMonitorNotesInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs"
|
||||
placeholder="External service"
|
||||
value={monitorServiceInput}
|
||||
onChange={(e) => setMonitorServiceInput(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
|
||||
disabled={!monitorAtInput}
|
||||
onClick={saveMonitor}
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
{issue.executionPolicy?.monitor ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={clearMonitor}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectedIssueLabels = useMemo(() => {
|
||||
const selectedIds = issue.labelIds ?? [];
|
||||
if (selectedIds.length === 0) return issue.labels ?? [];
|
||||
@@ -985,6 +1670,9 @@ export function IssueProperties({
|
||||
: [...blockedByIds, blockedByIssueId];
|
||||
onUpdate({ blockedByIssueIds: nextBlockedByIds });
|
||||
};
|
||||
const removeBlockedBy = (blockedByIssueId: string) => {
|
||||
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
|
||||
};
|
||||
|
||||
const blockedByContent = (
|
||||
<>
|
||||
@@ -1091,6 +1779,31 @@ export function IssueProperties({
|
||||
{assigneeContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{showAssigneeAdapterOptions ? (
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Model"
|
||||
open={assigneeOptionsOpen}
|
||||
onOpenChange={setAssigneeOptionsOpen}
|
||||
triggerContent={assigneeOptionsTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-72")}
|
||||
extra={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={() => updateAssigneeAdapterOverrides(null)}
|
||||
aria-label="Clear adapter options"
|
||||
title="Clear adapter options"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{assigneeOptionsContent}
|
||||
</PropertyPicker>
|
||||
) : null}
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Project"
|
||||
@@ -1132,7 +1845,7 @@ export function IssueProperties({
|
||||
<div>
|
||||
<PropertyRow label="Blocked by">
|
||||
{(issue.blockedBy ?? []).map((relation) => (
|
||||
<IssueReferencePill key={relation.id} issue={relation} />
|
||||
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
|
||||
))}
|
||||
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
|
||||
</PropertyRow>
|
||||
@@ -1145,7 +1858,7 @@ export function IssueProperties({
|
||||
) : (
|
||||
<PropertyRow label="Blocked by">
|
||||
{(issue.blockedBy ?? []).map((relation) => (
|
||||
<IssueReferencePill key={relation.id} issue={relation} />
|
||||
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
|
||||
))}
|
||||
<Popover
|
||||
open={blockedByOpen}
|
||||
@@ -1248,6 +1961,34 @@ export function IssueProperties({
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{showScheduledRetryRow && scheduledRetryContent ? (
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Scheduled retry"
|
||||
open={scheduledRetryOpen}
|
||||
onOpenChange={setScheduledRetryOpen}
|
||||
triggerContent={scheduledRetryTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-[32rem]")}
|
||||
extra={scheduledRetryAttemptBadge}
|
||||
>
|
||||
{scheduledRetryContent}
|
||||
</PropertyPicker>
|
||||
) : null}
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Monitor"
|
||||
open={monitorOpen}
|
||||
onOpenChange={setMonitorOpen}
|
||||
triggerContent={monitorTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-[32rem]")}
|
||||
extra={monitorAttemptBadge}
|
||||
>
|
||||
{monitorContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{issue.requestDepth > 0 && (
|
||||
<PropertyRow label="Depth">
|
||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||
@@ -1283,17 +2024,6 @@ export function IssueProperties({
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{workspaceFilterId && (
|
||||
<PropertyRow label="Tasks">
|
||||
<Link
|
||||
to={issuesWorkspaceFilterHref(workspaceFilterId)}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
View workspace tasks
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.currentExecutionWorkspace?.branchName && (
|
||||
<PropertyRow label="Branch">
|
||||
<TruncatedCopyable
|
||||
@@ -1336,16 +2066,16 @@ export function IssueProperties({
|
||||
)}
|
||||
{issue.startedAt && (
|
||||
<PropertyRow label="Started">
|
||||
<span className="text-sm">{formatDate(issue.startedAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(issue.startedAt)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.completedAt && (
|
||||
<PropertyRow label="Completed">
|
||||
<span className="text-sm">{formatDate(issue.completedAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(issue.completedAt)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Created">
|
||||
<span className="text-sm">{formatDate(issue.createdAt)}</span>
|
||||
<span className="text-sm">{formatDateTime(issue.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Updated">
|
||||
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
||||
|
||||
@@ -68,6 +68,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
lastExternalCommentAt: null,
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
workMode: overrides.workMode ?? "standard",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,6 +228,22 @@ describe("IssueRow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders planning mode marker for planning work mode issues", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={createIssue({ workMode: "planning" })} />);
|
||||
});
|
||||
|
||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.textContent).toContain("Planning");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders without error when titleSuffix is omitted", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -241,4 +258,60 @@ describe("IssueRow", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("flags rows blocked by an assigned-backlog leaf with a parked-work badge", () => {
|
||||
const root = createRoot(container);
|
||||
const issue = createIssue({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "PAP-2",
|
||||
title: "Parked child",
|
||||
status: "backlog",
|
||||
priority: "high",
|
||||
assigneeAgentId: "agent-99",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={issue} />);
|
||||
});
|
||||
|
||||
const badges = container.querySelectorAll('[data-testid="issue-row-parked-blocker"]');
|
||||
expect(badges.length).toBeGreaterThan(0);
|
||||
expect(badges[0]?.textContent).toContain("Blocked by parked work");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show the parked-work badge when assigned blocker is not in backlog", () => {
|
||||
const root = createRoot(container);
|
||||
const issue = createIssue({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "PAP-2",
|
||||
title: "Active child",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
assigneeAgentId: "agent-99",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={issue} />);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="issue-row-parked-blocker"]')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Eye, X } from "lucide-react";
|
||||
import { Eye, Flag, X } from "lucide-react";
|
||||
import {
|
||||
createIssueDetailPath,
|
||||
rememberIssueDetailLocationState,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
|
||||
type UnreadState = "hidden" | "visible" | "fading";
|
||||
|
||||
@@ -83,6 +84,24 @@ export function IssueRow({
|
||||
{checklistStepNumber}.
|
||||
</span>
|
||||
) : null;
|
||||
const planningModeIndicator = issue.workMode === "planning" ? (
|
||||
<span
|
||||
className="ml-1.5 inline-flex shrink-0 items-center rounded-full border border-amber-500/60 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300"
|
||||
title="This issue is in planning mode."
|
||||
>
|
||||
Planning
|
||||
</span>
|
||||
) : null;
|
||||
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
|
||||
<span
|
||||
data-testid="issue-row-parked-blocker"
|
||||
className="ml-1.5 inline-flex shrink-0 items-center gap-0.5 rounded-full border border-amber-500/60 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300"
|
||||
title="Blocked by parked work — at least one assigned blocker is in backlog and will not wake its assignee."
|
||||
>
|
||||
<Flag className="h-2.5 w-2.5" aria-hidden />
|
||||
Blocked by parked work
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -104,6 +123,8 @@ export function IssueRow({
|
||||
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
|
||||
{productivityReviewIndicator}
|
||||
{planningModeIndicator}
|
||||
{parkedBlockerIndicator}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
|
||||
@@ -128,6 +149,8 @@ export function IssueRow({
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{identifier}
|
||||
</span>
|
||||
{planningModeIndicator}
|
||||
{parkedBlockerIndicator}
|
||||
</>
|
||||
)}
|
||||
{mobileMeta ? (
|
||||
|
||||
@@ -114,6 +114,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
createdAt: new Date("2026-04-18T19:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-18T19:00:00.000Z"),
|
||||
...overrides,
|
||||
workMode: overrides.workMode ?? "standard",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -317,6 +318,44 @@ describe("IssueRunLedger", () => {
|
||||
expect(container.textContent).toContain("Manual intervention required");
|
||||
});
|
||||
|
||||
it("labels max-turn stops and continuation retries without confusing them with per-run turns", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
createRun({
|
||||
runId: "run-scheduled-continuation",
|
||||
status: "scheduled_retry",
|
||||
finishedAt: null,
|
||||
livenessState: null,
|
||||
livenessReason: null,
|
||||
retryOfRunId: "run-max-turns",
|
||||
scheduledRetryAt: "2026-04-18T20:15:00.000Z",
|
||||
scheduledRetryAttempt: 1,
|
||||
scheduledRetryReason: "max_turns_continuation",
|
||||
}),
|
||||
createRun({
|
||||
runId: "run-max-turns",
|
||||
resultJson: { stopReason: "max_turns_exhausted" },
|
||||
createdAt: "2026-04-18T19:57:00.000Z",
|
||||
}),
|
||||
createRun({
|
||||
runId: "run-continuation-exhausted",
|
||||
status: "failed",
|
||||
createdAt: "2026-04-18T19:56:00.000Z",
|
||||
retryOfRunId: "run-max-turns",
|
||||
scheduledRetryAttempt: 3,
|
||||
scheduledRetryReason: "max_turns_continuation",
|
||||
retryExhaustedReason: "Bounded retry exhausted after 3 scheduled attempts; no further automatic retry will be queued",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Continuation scheduled");
|
||||
expect(container.textContent).toContain("Max-turn continuation");
|
||||
expect(container.textContent).toContain("Next continuation");
|
||||
expect(container.textContent).toContain("Stop max turns exhausted");
|
||||
expect(container.textContent).toContain("Continuation exhausted");
|
||||
});
|
||||
|
||||
it("shows timeout, cancel, and budget stop reasons without raw logs", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
|
||||
@@ -311,6 +311,7 @@ function stopReasonLabel(run: RunForIssue) {
|
||||
if (timeoutFired || stopReason === "timeout") {
|
||||
return timeoutText ? `timeout (${timeoutText})` : "timeout";
|
||||
}
|
||||
if (stopReason === "max_turns_exhausted" || stopReason === "turn_limit_exhausted") return "max turns exhausted";
|
||||
if (stopReason === "budget_paused") return "budget paused";
|
||||
if (stopReason === "cancelled") return "cancelled";
|
||||
if (stopReason === "paused") return "paused by board";
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps, ReactNode } 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 type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
|
||||
import { IssueScheduledRetryCard } from "./IssueScheduledRetryCard";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
|
||||
const retryNowMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
retryScheduledRetryNow: retryNowMock,
|
||||
},
|
||||
}));
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let dateNowSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||
|
||||
const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime();
|
||||
|
||||
const baseRetry: IssueScheduledRetry = {
|
||||
runId: "run-00000000",
|
||||
status: "scheduled_retry",
|
||||
agentId: "agent-1",
|
||||
agentName: "ClaudeCoder",
|
||||
retryOfRunId: "run-prev-1234567",
|
||||
scheduledRetryAt: "2026-04-18T20:15:00.000Z",
|
||||
scheduledRetryAttempt: 4,
|
||||
scheduledRetryReason: "transient_failure",
|
||||
retryExhaustedReason: null,
|
||||
error: "Upstream provider rate limited",
|
||||
errorCode: "rate_limited",
|
||||
};
|
||||
|
||||
function buildRetryResponse(outcome: IssueRetryNowOutcome) {
|
||||
return {
|
||||
outcome,
|
||||
message:
|
||||
outcome === "promoted"
|
||||
? "Promoted scheduled retry"
|
||||
: outcome === "already_promoted"
|
||||
? "Scheduled retry already promoted"
|
||||
: outcome === "no_scheduled_retry"
|
||||
? "No scheduled retry"
|
||||
: "Promotion suppressed by gate",
|
||||
scheduledRetry:
|
||||
outcome === "promoted" || outcome === "already_promoted"
|
||||
? { ...baseRetry, status: "queued" as const }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForUi(assertion: () => void) {
|
||||
await vi.waitFor(async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
assertion();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRetryButtonText(expected: string) {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if ((getRetryNowButton()?.textContent ?? "").includes(expected)) return;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
expect(getRetryNowButton()!.textContent ?? "").toContain(expected);
|
||||
}
|
||||
|
||||
function renderWithProviders(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{ui}</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW);
|
||||
retryNowMock.mockReset();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
dateNowSpy?.mockRestore();
|
||||
});
|
||||
|
||||
function getCard() {
|
||||
return container.querySelector('[data-testid="issue-scheduled-retry-card"]');
|
||||
}
|
||||
|
||||
function getRetryNowButton() {
|
||||
return container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="issue-scheduled-retry-card-retry-now"]',
|
||||
);
|
||||
}
|
||||
|
||||
describe("IssueScheduledRetryCard", () => {
|
||||
it("renders nothing when there is no scheduled retry", () => {
|
||||
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={null} />);
|
||||
expect(getCard()).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when status is not scheduled_retry", () => {
|
||||
renderWithProviders(
|
||||
<IssueScheduledRetryCard
|
||||
issueId="issue-1"
|
||||
scheduledRetry={{ ...baseRetry, status: "queued" }}
|
||||
/>,
|
||||
);
|
||||
expect(getCard()).toBeNull();
|
||||
});
|
||||
|
||||
it("shows attempt count, reason, absolute and relative timestamps", () => {
|
||||
renderWithProviders(
|
||||
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
|
||||
);
|
||||
const card = getCard();
|
||||
expect(card).not.toBeNull();
|
||||
const text = card!.textContent ?? "";
|
||||
expect(text).toContain("Retry scheduled");
|
||||
expect(text).toContain("Attempt 4");
|
||||
expect(text).toContain("Transient failure");
|
||||
expect(text).toContain("Automatic retry in 15m");
|
||||
expect(text).toContain("run-prev");
|
||||
});
|
||||
|
||||
it("uses continuation copy for max-turn continuations", () => {
|
||||
renderWithProviders(
|
||||
<IssueScheduledRetryCard
|
||||
issueId="issue-1"
|
||||
scheduledRetry={{ ...baseRetry, scheduledRetryReason: "max_turns_continuation" }}
|
||||
/>,
|
||||
);
|
||||
const text = getCard()?.textContent ?? "";
|
||||
expect(text).toContain("Continuation scheduled");
|
||||
expect(text).toContain("Automatic continuation");
|
||||
expect(text).toContain("Pulls continuation forward immediately");
|
||||
});
|
||||
|
||||
it("uses 'due now' label when scheduledRetryAt is at the current time", () => {
|
||||
renderWithProviders(
|
||||
<IssueScheduledRetryCard
|
||||
issueId="issue-1"
|
||||
scheduledRetry={{ ...baseRetry, scheduledRetryAt: "2026-04-18T20:00:10.000Z" }}
|
||||
/>,
|
||||
);
|
||||
const text = getCard()?.textContent ?? "";
|
||||
expect(text).toContain("Automatic retry due now");
|
||||
});
|
||||
|
||||
it("invokes retry-now and shows promoted state on success", async () => {
|
||||
retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
|
||||
renderWithProviders(
|
||||
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
|
||||
);
|
||||
const button = getRetryNowButton();
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
button!.click();
|
||||
});
|
||||
await waitForRetryButtonText("Promoted");
|
||||
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
|
||||
const finalButton = getRetryNowButton();
|
||||
expect(finalButton!.textContent ?? "").toContain("Promoted");
|
||||
expect(finalButton!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows already promoted state when backend reports duplicate click", async () => {
|
||||
retryNowMock.mockResolvedValue(buildRetryResponse("already_promoted"));
|
||||
renderWithProviders(
|
||||
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
|
||||
);
|
||||
act(() => {
|
||||
getRetryNowButton()!.click();
|
||||
});
|
||||
await waitForRetryButtonText("Already promoted");
|
||||
expect(getRetryNowButton()!.textContent ?? "").toContain("Already promoted");
|
||||
expect(container.querySelector('[data-testid="issue-scheduled-retry-error-band"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an inline error band on backend failure", async () => {
|
||||
retryNowMock.mockRejectedValue(new Error("Server error"));
|
||||
renderWithProviders(
|
||||
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
|
||||
);
|
||||
act(() => {
|
||||
getRetryNowButton()!.click();
|
||||
});
|
||||
await waitForUi(() => {
|
||||
const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]');
|
||||
expect(band).not.toBeNull();
|
||||
expect((band?.textContent ?? "")).toContain("Server error");
|
||||
expect(getRetryNowButton()!.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces gate-suppressed outcome via the inline error band", async () => {
|
||||
retryNowMock.mockResolvedValue(buildRetryResponse("gate_suppressed"));
|
||||
renderWithProviders(
|
||||
<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={baseRetry} />,
|
||||
);
|
||||
act(() => {
|
||||
getRetryNowButton()!.click();
|
||||
});
|
||||
await waitForUi(() => {
|
||||
const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]');
|
||||
expect(band).not.toBeNull();
|
||||
expect((band?.textContent ?? "")).toContain("Promotion suppressed");
|
||||
expect(getRetryNowButton()!.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Clock, RotateCcw, AlertCircle, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import { formatMonitorOffset } from "@/lib/issue-monitor";
|
||||
import { formatRetryReason } from "@/lib/runRetryState";
|
||||
import type { IssueScheduledRetry } from "@paperclipai/shared";
|
||||
import { useRetryNowMutation, type RetryNowError } from "../hooks/useRetryNowMutation";
|
||||
|
||||
const MAX_TURN_CONTINUATION = "max_turns_continuation";
|
||||
|
||||
function isContinuationReason(reason: string | null | undefined) {
|
||||
return reason === MAX_TURN_CONTINUATION;
|
||||
}
|
||||
|
||||
function shortRunId(runId: string | null | undefined) {
|
||||
return typeof runId === "string" && runId.length >= 8 ? runId.slice(0, 8) : runId ?? "";
|
||||
}
|
||||
|
||||
interface IssueScheduledRetryCardProps {
|
||||
issueId: string | null | undefined;
|
||||
scheduledRetry: IssueScheduledRetry | null | undefined;
|
||||
}
|
||||
|
||||
export function IssueScheduledRetryCard({
|
||||
issueId,
|
||||
scheduledRetry,
|
||||
}: IssueScheduledRetryCardProps) {
|
||||
const retryNow = useRetryNowMutation(issueId);
|
||||
|
||||
if (!scheduledRetry || !issueId) return null;
|
||||
if (scheduledRetry.status !== "scheduled_retry") return null;
|
||||
|
||||
const continuation = isContinuationReason(scheduledRetry.scheduledRetryReason);
|
||||
const dueAtIso = scheduledRetry.scheduledRetryAt
|
||||
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
|
||||
: null;
|
||||
const relative = dueAtIso ? formatMonitorOffset(dueAtIso) : null;
|
||||
const absolute = scheduledRetry.scheduledRetryAt
|
||||
? formatDateTime(scheduledRetry.scheduledRetryAt)
|
||||
: null;
|
||||
const reason = formatRetryReason(scheduledRetry.scheduledRetryReason);
|
||||
const attempt =
|
||||
typeof scheduledRetry.scheduledRetryAttempt === "number"
|
||||
&& Number.isFinite(scheduledRetry.scheduledRetryAttempt)
|
||||
&& scheduledRetry.scheduledRetryAttempt > 0
|
||||
? scheduledRetry.scheduledRetryAttempt
|
||||
: null;
|
||||
|
||||
const badgeLabel = continuation ? "Continuation scheduled" : "Retry scheduled";
|
||||
const titleAction = continuation ? "Automatic continuation" : "Automatic retry";
|
||||
let titleSuffix: string;
|
||||
if (relative === "now") {
|
||||
titleSuffix = "due now";
|
||||
} else if (relative) {
|
||||
titleSuffix = relative;
|
||||
} else {
|
||||
titleSuffix = "pending schedule";
|
||||
}
|
||||
const title = `${titleAction} ${titleSuffix}`;
|
||||
|
||||
const helperIdle = continuation
|
||||
? "Pulls continuation forward immediately"
|
||||
: "Pulls retry forward immediately";
|
||||
const isError = retryNow.isError || retryNow.lastError !== null;
|
||||
const isSuccessTransient = retryNow.isSuccess
|
||||
&& (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="issue-scheduled-retry-card"
|
||||
className="mb-3 rounded-lg border border-cyan-500/30 bg-cyan-500/5 px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 font-medium text-cyan-700 dark:text-cyan-300">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
{badgeLabel}
|
||||
</span>
|
||||
{attempt !== null ? (
|
||||
<span className="text-muted-foreground">Attempt {attempt}</span>
|
||||
) : null}
|
||||
{reason ? (
|
||||
<span className="text-muted-foreground">{reason}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-foreground">{title}</div>
|
||||
{(absolute || scheduledRetry.retryOfRunId) ? (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{absolute ? <span>{absolute}</span> : null}
|
||||
{absolute && scheduledRetry.retryOfRunId ? <span>{" · "}</span> : null}
|
||||
{scheduledRetry.retryOfRunId ? (
|
||||
<span>
|
||||
Replaces run{" "}
|
||||
<Link
|
||||
to={`/agents/${scheduledRetry.agentId}/runs/${scheduledRetry.retryOfRunId}`}
|
||||
className="font-mono text-foreground hover:underline"
|
||||
>
|
||||
{shortRunId(scheduledRetry.retryOfRunId)}
|
||||
</Link>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{scheduledRetry.error ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Last attempt failed: {scheduledRetry.error}. Paperclip will retry automatically.
|
||||
</div>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<RetryErrorBand
|
||||
error={retryNow.lastError}
|
||||
onRetry={() => {
|
||||
retryNow.reset();
|
||||
retryNow.mutate();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-1 sm:items-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 shadow-none"
|
||||
onClick={() => retryNow.mutate()}
|
||||
disabled={retryNow.isPending || isSuccessTransient}
|
||||
data-testid="issue-scheduled-retry-card-retry-now"
|
||||
>
|
||||
{retryNow.isPending ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
Retrying…
|
||||
</span>
|
||||
) : isSuccessTransient ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Retry now
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-right text-xs text-muted-foreground sm:max-w-[12rem]">
|
||||
{retryNow.isPending
|
||||
? "Promoting scheduled retry"
|
||||
: isSuccessTransient
|
||||
? retryNow.data?.outcome === "already_promoted"
|
||||
? "Already promoted — run starting"
|
||||
: "Promoted — run starting"
|
||||
: helperIdle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RetryErrorBandProps {
|
||||
error: RetryNowError | null;
|
||||
onRetry: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RetryErrorBand({ error, onRetry, className }: RetryErrorBandProps) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/5 px-2 py-1.5 text-xs text-rose-700 dark:text-rose-300",
|
||||
className,
|
||||
)}
|
||||
role="alert"
|
||||
data-testid="issue-scheduled-retry-error-band"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">Couldn't retry now</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{error.message}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="shrink-0 font-medium text-rose-700 hover:underline dark:text-rose-300"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +110,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
labelIds: [],
|
||||
currentExecutionWorkspace: null,
|
||||
...overrides,
|
||||
workMode: overrides.workMode ?? "standard",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AnchorHTMLAttributes, ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Issue, Project } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssuesList } from "./IssuesList";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -179,6 +179,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
lastActivityAt: null,
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
workMode: overrides.workMode ?? "standard",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -399,6 +400,70 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses workspace group defaults when creating an issue from a grouped section", async () => {
|
||||
localStorage.setItem(
|
||||
"paperclip:test-issues:company-1",
|
||||
JSON.stringify({ groupBy: "workspace", sortField: "updated", sortDir: "desc" }),
|
||||
);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
||||
{
|
||||
id: "execution-workspace-1",
|
||||
name: "Feature Branch",
|
||||
mode: "isolated_workspace",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const issue = createIssue({
|
||||
id: "issue-workspace",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
});
|
||||
const project = {
|
||||
id: "project-1",
|
||||
name: "Paperclip App",
|
||||
color: null,
|
||||
workspaces: [{ id: "project-workspace-1", name: "Primary workspace" }],
|
||||
primaryWorkspace: { id: "project-workspace-1" },
|
||||
executionWorkspacePolicy: { defaultProjectWorkspaceId: "project-workspace-1" },
|
||||
} as Project;
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[issue]}
|
||||
agents={[]}
|
||||
projects={[project]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const button = container.querySelector<HTMLButtonElement>('button[aria-label="New issue in Feature Branch"]');
|
||||
expect(button).not.toBeNull();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const button = container.querySelector<HTMLButtonElement>('button[aria-label="New issue in Feature Branch"]');
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dialogState.openNewIssue).toHaveBeenCalledWith({
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
executionWorkspaceMode: "reuse_existing",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the opt-in sub-issue progress summary with workflow next-up linking", async () => {
|
||||
const doneIssue = createIssue({
|
||||
id: "issue-done",
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
resolveIssueWorkspaceName,
|
||||
type InboxIssueColumn,
|
||||
} from "../lib/inbox";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, formatDurationMs, formatTokens } from "../lib/utils";
|
||||
import {
|
||||
InboxIssueMetaLeading,
|
||||
InboxIssueTrailingColumns,
|
||||
@@ -66,6 +66,7 @@ import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
||||
import { statusBadge } from "../lib/status-colors";
|
||||
import { workflowSort } from "../lib/workflow-sort";
|
||||
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
|
||||
import { ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
|
||||
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
|
||||
const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
||||
@@ -113,7 +114,7 @@ export type IssueSortField = "status" | "priority" | "title" | "created" | "upda
|
||||
export type IssueViewState = IssueFilterState & {
|
||||
sortField: IssueSortField;
|
||||
sortDir: "asc" | "desc";
|
||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||
groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none";
|
||||
viewMode: "list" | "board";
|
||||
nestingEnabled: boolean;
|
||||
collapsedGroups: string[];
|
||||
@@ -363,6 +364,12 @@ interface IssuesListProps {
|
||||
createIssueLabel?: string;
|
||||
defaultSortField?: IssueSortField;
|
||||
showProgressSummary?: boolean;
|
||||
/**
|
||||
* When set together with `showProgressSummary`, the progress strip fetches
|
||||
* the recursive cost-summary for this parent issue and renders aggregate
|
||||
* tokens + wall-clock runtime for every run in the tree.
|
||||
*/
|
||||
parentIssueIdForCostSummary?: string;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
hasMoreIssues?: boolean;
|
||||
isLoadingMoreIssues?: boolean;
|
||||
@@ -438,9 +445,11 @@ function IssueSearchInput({
|
||||
function SubIssueProgressSummaryStrip({
|
||||
summary,
|
||||
issueLinkState,
|
||||
parentIssueIdForCostSummary,
|
||||
}: {
|
||||
summary: SubIssueProgressSummary;
|
||||
issueLinkState?: unknown;
|
||||
parentIssueIdForCostSummary?: string;
|
||||
}) {
|
||||
const target = summary.target;
|
||||
const targetIssue = target?.issue ?? null;
|
||||
@@ -450,6 +459,21 @@ function SubIssueProgressSummaryStrip({
|
||||
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
|
||||
.filter((entry) => entry.count > 0);
|
||||
|
||||
// Refresh fast enough that the runtime ticks up while a sub-issue is still
|
||||
// running, but slow enough not to hammer the recursive CTE on idle trees.
|
||||
const hasInProgress = summary.inProgressCount > 0;
|
||||
const { data: costSummary } = useQuery({
|
||||
queryKey: queryKeys.issues.costSummary(parentIssueIdForCostSummary ?? "pending", { excludeRoot: true }),
|
||||
queryFn: () => issuesApi.getCostSummary(parentIssueIdForCostSummary!, { excludeRoot: true }),
|
||||
enabled: !!parentIssueIdForCostSummary,
|
||||
refetchInterval: hasInProgress ? 5_000 : false,
|
||||
});
|
||||
|
||||
const totalTokens = costSummary
|
||||
? costSummary.inputTokens + costSummary.cachedInputTokens + costSummary.outputTokens
|
||||
: 0;
|
||||
const showCostSummary = !!costSummary && (costSummary.runCount > 0 || totalTokens > 0);
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-background p-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
@@ -464,6 +488,23 @@ function SubIssueProgressSummaryStrip({
|
||||
<span className="text-muted-foreground">
|
||||
{summary.blockedCount} blocked
|
||||
</span>
|
||||
{showCostSummary && (
|
||||
<>
|
||||
<span
|
||||
className="text-muted-foreground tabular-nums"
|
||||
title={`${costSummary.runCount.toLocaleString()} run${
|
||||
costSummary.runCount === 1 ? "" : "s"
|
||||
} across ${costSummary.issueCount} sub-issue${
|
||||
costSummary.issueCount === 1 ? "" : "s"
|
||||
}`}
|
||||
>
|
||||
{formatTokens(totalTokens)} tokens
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDurationMs(costSummary.runtimeMs)} runtime
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
@@ -535,6 +576,7 @@ export function IssuesList({
|
||||
createIssueLabel,
|
||||
defaultSortField,
|
||||
showProgressSummary = false,
|
||||
parentIssueIdForCostSummary,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
hasMoreIssues = false,
|
||||
isLoadingMoreIssues = false,
|
||||
@@ -698,10 +740,10 @@ export function IssuesList({
|
||||
}, [projects]);
|
||||
|
||||
const projectWorkspaceById = useMemo(() => {
|
||||
const map = new Map<string, { name: string }>();
|
||||
const map = new Map<string, { name: string; projectId: string }>();
|
||||
for (const project of projects ?? []) {
|
||||
for (const workspace of project.workspaces ?? []) {
|
||||
map.set(workspace.id, { name: workspace.name || project.name });
|
||||
map.set(workspace.id, { name: workspace.name || project.name, projectId: project.id });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@@ -728,16 +770,21 @@ export function IssuesList({
|
||||
name: string;
|
||||
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
projectId: string | null;
|
||||
}>();
|
||||
for (const workspace of executionWorkspaces) {
|
||||
const projectWorkspace = workspace.projectWorkspaceId
|
||||
? projectWorkspaceById.get(workspace.projectWorkspaceId) ?? null
|
||||
: null;
|
||||
map.set(workspace.id, {
|
||||
name: workspace.name,
|
||||
mode: workspace.mode,
|
||||
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
|
||||
projectId: projectWorkspace?.projectId ?? null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [executionWorkspaces]);
|
||||
}, [executionWorkspaces, projectWorkspaceById]);
|
||||
const issueFilterWorkspaceContext = useMemo(() => ({
|
||||
executionWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
@@ -995,6 +1042,22 @@ export function IssuesList({
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}
|
||||
if (viewState.groupBy === "project") {
|
||||
const groups = groupBy(filtered, (issue) => issue.projectId ?? "__no_project");
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
if (a === "__no_project") return 1;
|
||||
if (b === "__no_project") return -1;
|
||||
const labelA = projectById.get(a)?.name ?? a;
|
||||
const labelB = projectById.get(b)?.name ?? b;
|
||||
return labelA.localeCompare(labelB);
|
||||
})
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: key === "__no_project" ? "No Project" : (projectById.get(key)?.name ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}
|
||||
if (viewState.groupBy === "parent") {
|
||||
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
|
||||
return Object.keys(groups)
|
||||
@@ -1035,6 +1098,7 @@ export function IssuesList({
|
||||
workspaceNameMap,
|
||||
issueTitleMap,
|
||||
companyUserLabelMap,
|
||||
projectById,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1120,7 +1184,8 @@ export function IssuesList({
|
||||
};
|
||||
}, [canLoadMoreIssues, hasMoreIssues, hasMoreRenderedRows, loadMoreIssueRows]);
|
||||
|
||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||
const newIssueDefaults = useCallback((group?: { key: string; items: Issue[] }) => {
|
||||
const groupKey = group?.key;
|
||||
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
|
||||
if (groupKey) {
|
||||
@@ -1130,6 +1195,28 @@ export function IssuesList({
|
||||
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||
else defaults.assigneeAgentId = groupKey;
|
||||
}
|
||||
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
|
||||
else if (viewState.groupBy === "workspace" && groupKey !== "__no_workspace") {
|
||||
const representativeIssue = group?.items.find((issue) => issue.executionWorkspaceId === groupKey) ?? null;
|
||||
const executionWorkspace = executionWorkspaceById.get(groupKey);
|
||||
if (executionWorkspace) {
|
||||
defaults.executionWorkspaceId = groupKey;
|
||||
defaults.executionWorkspaceMode = "reuse_existing";
|
||||
if (executionWorkspace.projectWorkspaceId) defaults.projectWorkspaceId = executionWorkspace.projectWorkspaceId;
|
||||
const groupedProjectId = executionWorkspace.projectId
|
||||
?? (executionWorkspace.projectWorkspaceId
|
||||
? projectWorkspaceById.get(executionWorkspace.projectWorkspaceId)?.projectId
|
||||
: null)
|
||||
?? (representativeIssue?.executionWorkspaceId === groupKey ? representativeIssue.projectId : null);
|
||||
if (groupedProjectId) defaults.projectId = groupedProjectId;
|
||||
} else {
|
||||
const projectWorkspace = projectWorkspaceById.get(groupKey);
|
||||
if (projectWorkspace) {
|
||||
defaults.projectWorkspaceId = groupKey;
|
||||
defaults.projectId = projectWorkspace.projectId;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
||||
const parentIssue = issueById.get(groupKey);
|
||||
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
|
||||
@@ -1137,12 +1224,20 @@ export function IssuesList({
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}, [baseCreateIssueDefaults, currentUserId, issueById, projectId, viewState.groupBy]);
|
||||
}, [
|
||||
baseCreateIssueDefaults,
|
||||
currentUserId,
|
||||
executionWorkspaceById,
|
||||
issueById,
|
||||
projectId,
|
||||
projectWorkspaceById,
|
||||
viewState.groupBy,
|
||||
]);
|
||||
|
||||
const createActionLabel = createIssueLabel ? `Create ${createIssueLabel}` : "Create Issue";
|
||||
const createButtonLabel = createIssueLabel ? `New ${createIssueLabel}` : "New Issue";
|
||||
const openCreateIssueDialog = useCallback((groupKey?: string) => {
|
||||
openNewIssue(newIssueDefaults(groupKey));
|
||||
const openCreateIssueDialog = useCallback((group?: { key: string; items: Issue[] }) => {
|
||||
openNewIssue(newIssueDefaults(group));
|
||||
}, [newIssueDefaults, openNewIssue]);
|
||||
|
||||
const filterToWorkspace = useCallback((workspaceId: string) => {
|
||||
@@ -1174,7 +1269,11 @@ export function IssuesList({
|
||||
return (
|
||||
<div ref={rootRef} className="space-y-4">
|
||||
{progressSummary ? (
|
||||
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
|
||||
<SubIssueProgressSummaryStrip
|
||||
summary={progressSummary}
|
||||
issueLinkState={issueLinkState}
|
||||
parentIssueIdForCostSummary={parentIssueIdForCostSummary}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Toolbar */}
|
||||
@@ -1306,6 +1405,7 @@ export function IssuesList({
|
||||
["status", "Status"],
|
||||
["priority", "Priority"],
|
||||
["assignee", "Assignee"],
|
||||
["project", "Project"],
|
||||
["workspace", "Workspace"],
|
||||
["parent", "Parent Issue"],
|
||||
["none", "None"],
|
||||
@@ -1388,8 +1488,10 @@ export function IssuesList({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => openCreateIssueDialog(group.key)}
|
||||
className="-mr-2 text-muted-foreground"
|
||||
title={`New issue in ${group.label}`}
|
||||
aria-label={`New issue in ${group.label}`}
|
||||
onClick={() => openCreateIssueDialog(group)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -1528,6 +1630,16 @@ export function IssuesList({
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
{isSuccessfulRunHandoffRequired(issue) ? (
|
||||
<span
|
||||
className="ml-1.5 inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
|
||||
aria-label="Needs next step"
|
||||
title="This issue needs a next step"
|
||||
>
|
||||
<CircleDot className="h-3 w-3" />
|
||||
Needs next step
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
className={isMutedIssue ? "opacity-70" : undefined}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { JsonSchemaForm } from "./JsonSchemaForm";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("JsonSchemaForm secret-ref rendering", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders multiline secret-ref fields as textareas", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
sshPrivateKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
maxLength: 4096,
|
||||
},
|
||||
},
|
||||
}}
|
||||
values={{ sshPrivateKey: "secret" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector("textarea")).not.toBeNull();
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -478,6 +478,7 @@ const SecretField = React.memo(({
|
||||
description,
|
||||
error,
|
||||
defaultValue,
|
||||
maxLength,
|
||||
}: {
|
||||
value: unknown;
|
||||
onChange: (val: unknown) => void;
|
||||
@@ -487,8 +488,10 @@ const SecretField = React.memo(({
|
||||
description?: string;
|
||||
error?: string;
|
||||
defaultValue?: unknown;
|
||||
maxLength?: number;
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD;
|
||||
return (
|
||||
<FieldWrapper
|
||||
label={label}
|
||||
@@ -500,34 +503,83 @@ const SecretField = React.memo(({
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="pr-10"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isTextArea ? (
|
||||
<div className="relative">
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="min-h-[140px] pr-10 font-mono text-xs"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
// Render a placeholder summary instead of the secret content while
|
||||
// hidden. This avoids exposing multi-line secrets (e.g. SSH
|
||||
// private keys) on screen-shares; clicking the eye toggle reveals
|
||||
// the editable textarea above.
|
||||
value={
|
||||
String(value ?? "").length === 0
|
||||
? ""
|
||||
: `Sensitive — ${String(value ?? "").length} characters hidden. Click the eye to reveal.`
|
||||
}
|
||||
readOnly
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="pr-10"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FieldWrapper>
|
||||
);
|
||||
});
|
||||
@@ -885,6 +937,7 @@ const FormField = React.memo(({
|
||||
description={propSchema.description}
|
||||
error={error}
|
||||
defaultValue={propSchema.default}
|
||||
maxLength={typeof propSchema.maxLength === "number" ? propSchema.maxLength : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
|
||||
|
||||
const boardStatuses = [
|
||||
"backlog",
|
||||
@@ -159,6 +161,16 @@ function KanbanCard({
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{isSuccessfulRunHandoffRequired(issue) ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
|
||||
title="This issue needs a next step"
|
||||
aria-label="Needs next step"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Next step
|
||||
</span>
|
||||
) : null}
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
|
||||
@@ -17,6 +17,16 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockCompanyState = vi.hoisted(() => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
selectedCompanyId: "company-1",
|
||||
}));
|
||||
const mockPluginSlots = vi.hoisted(() => ({
|
||||
slots: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
|
||||
let currentPathname = "/PAP/dashboard";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
@@ -24,11 +34,10 @@ vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
|
||||
useNavigate: () => mockNavigate,
|
||||
useNavigationType: () => "PUSH",
|
||||
useParams: () => ({ companyPrefix: "PAP" }),
|
||||
}));
|
||||
|
||||
vi.mock("./CompanyRail", () => ({
|
||||
CompanyRail: () => <div>Company rail</div>,
|
||||
useParams: () => {
|
||||
const firstSegment = currentPathname.split("/").filter(Boolean)[0];
|
||||
return { companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./Sidebar", () => ({
|
||||
@@ -95,6 +104,33 @@ vi.mock("./SidebarAccountMenu", () => ({
|
||||
SidebarAccountMenu: () => <div>Account menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/slots", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/slots")>("../plugins/slots");
|
||||
return {
|
||||
resolveRouteSidebarSlot: actual.resolveRouteSidebarSlot,
|
||||
usePluginSlots: (params: Record<string, unknown>) => {
|
||||
mockUsePluginSlots(params);
|
||||
return {
|
||||
slots: mockPluginSlots.slots,
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
};
|
||||
},
|
||||
PluginSlotMount: ({
|
||||
slot,
|
||||
context,
|
||||
className,
|
||||
}: {
|
||||
slot: { displayName: string };
|
||||
context: Record<string, unknown>;
|
||||
className?: string;
|
||||
}) => {
|
||||
mockPluginSlotContexts.push(context);
|
||||
return <div data-plugin-slot-class={className}>Plugin route sidebar: {slot.displayName}</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
@@ -114,10 +150,10 @@ vi.mock("../context/PanelContext", () => ({
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||
companies: mockCompanyState.companies,
|
||||
loading: false,
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: mockCompanyState.selectedCompany,
|
||||
selectedCompanyId: mockCompanyState.selectedCompanyId,
|
||||
selectionSource: "manual",
|
||||
setSelectedCompanyId: mockSetSelectedCompanyId,
|
||||
}),
|
||||
@@ -179,6 +215,9 @@ describe("Layout", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentPathname = "/PAP/dashboard";
|
||||
mockCompanyState.companies = [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }];
|
||||
mockCompanyState.selectedCompany = { id: "company-1", issuePrefix: "PAP", name: "Paperclip" };
|
||||
mockCompanyState.selectedCompanyId = "company-1";
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
@@ -188,6 +227,8 @@ describe("Layout", () => {
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: false,
|
||||
});
|
||||
mockPluginSlots.slots = [];
|
||||
mockPluginSlotContexts.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -215,6 +256,7 @@ describe("Layout", () => {
|
||||
expect(mockHealthApi.get).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Breadcrumbs");
|
||||
expect(container.textContent).toContain("Outlet content");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Authenticated private");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Sign-in is required and this instance is intended for private-network access.",
|
||||
@@ -227,6 +269,30 @@ describe("Layout", () => {
|
||||
|
||||
it("renders the company settings sidebar on company settings routes", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
type: "page",
|
||||
id: "company-page",
|
||||
displayName: "Company Page",
|
||||
exportName: "CompanyPage",
|
||||
routePath: "company",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "fake-plugin",
|
||||
pluginDisplayName: "Fake Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "company-sidebar",
|
||||
displayName: "Company Route Sidebar",
|
||||
exportName: "CompanySidebar",
|
||||
routePath: "company",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "fake-plugin",
|
||||
pluginDisplayName: "Fake Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
];
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
@@ -243,8 +309,217 @@ describe("Layout", () => {
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the instance settings sidebar on instance settings routes", async () => {
|
||||
currentPathname = "/instance/settings/general";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Company rail");
|
||||
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a route-scoped plugin sidebar for a matching plugin page route", async () => {
|
||||
currentPathname = "/PAP/wiki";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page",
|
||||
displayName: "Wiki Page",
|
||||
exportName: "WikiPage",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin",
|
||||
pluginDisplayName: "Wiki Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin",
|
||||
pluginDisplayName: "Wiki Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
];
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
|
||||
expect(container.querySelector("[data-plugin-slot-class='h-full w-full']")).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the route company context for plugin route sidebars on the first render", async () => {
|
||||
currentPathname = "/ALT/wiki";
|
||||
mockCompanyState.companies = [
|
||||
{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
{ id: "company-2", issuePrefix: "ALT", name: "Alternate" },
|
||||
];
|
||||
mockCompanyState.selectedCompany = { id: "company-1", issuePrefix: "PAP", name: "Paperclip" };
|
||||
mockCompanyState.selectedCompanyId = "company-1";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page",
|
||||
displayName: "Wiki Page",
|
||||
exportName: "WikiPage",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin",
|
||||
pluginDisplayName: "Wiki Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin",
|
||||
pluginDisplayName: "Wiki Plugin",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
];
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockUsePluginSlots).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-2",
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
expect(mockPluginSlotContexts).toContainEqual({
|
||||
companyId: "company-2",
|
||||
companyPrefix: "ALT",
|
||||
});
|
||||
expect(mockPluginSlotContexts).not.toContainEqual({
|
||||
companyId: "company-1",
|
||||
companyPrefix: "PAP",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the normal company sidebar when a plugin page route is ambiguous", async () => {
|
||||
currentPathname = "/PAP/wiki";
|
||||
mockPluginSlots.slots = [
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page-a",
|
||||
displayName: "Wiki Page A",
|
||||
exportName: "WikiPageA",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin-a",
|
||||
pluginDisplayName: "Wiki Plugin A",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page-b",
|
||||
displayName: "Wiki Page B",
|
||||
exportName: "WikiPageB",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-2",
|
||||
pluginKey: "wiki-plugin-b",
|
||||
pluginDisplayName: "Wiki Plugin B",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
routePath: "wiki",
|
||||
pluginId: "plugin-1",
|
||||
pluginKey: "wiki-plugin-a",
|
||||
pluginDisplayName: "Wiki Plugin A",
|
||||
pluginVersion: "1.0.0",
|
||||
},
|
||||
];
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
@@ -17,6 +16,7 @@ import { ToastViewport } from "./ToastViewport";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
@@ -40,9 +40,18 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||
import { cn } from "../lib/utils";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
|
||||
|
||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||
|
||||
function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null {
|
||||
if (!companyPrefix) return null;
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 2) return null;
|
||||
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return null;
|
||||
return segments[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function readRememberedInstanceSettingsPath(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||
try {
|
||||
@@ -84,6 +93,38 @@ export function Layout() {
|
||||
}, [companies, companyPrefix]);
|
||||
const hasUnknownCompanyPrefix =
|
||||
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
|
||||
const pluginRoutePath = useMemo(
|
||||
() => getCompanyRouteSegment(location.pathname, companyPrefix),
|
||||
[companyPrefix, location.pathname],
|
||||
);
|
||||
const routeSidebarCompanyId = matchedCompany?.id ?? null;
|
||||
const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null;
|
||||
const { slots: routeSidebarSlots } = usePluginSlots({
|
||||
slotTypes: ["page", "routeSidebar"],
|
||||
companyId: routeSidebarCompanyId,
|
||||
enabled: Boolean(routeSidebarCompanyId && pluginRoutePath),
|
||||
});
|
||||
const routeSidebarSlot = useMemo(
|
||||
() => resolveRouteSidebarSlot(routeSidebarSlots, pluginRoutePath),
|
||||
[pluginRoutePath, routeSidebarSlots],
|
||||
);
|
||||
const sidebarContext = useMemo(
|
||||
() => ({
|
||||
companyId: routeSidebarCompanyId,
|
||||
companyPrefix: routeSidebarCompanyPrefix,
|
||||
}),
|
||||
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
|
||||
);
|
||||
const companySidebar = routeSidebarSlot ? (
|
||||
<PluginSlotMount
|
||||
slot={routeSidebarSlot}
|
||||
context={sidebarContext}
|
||||
className="h-full w-full"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : (
|
||||
<Sidebar />
|
||||
);
|
||||
const { data: health } = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
@@ -336,14 +377,15 @@ export function Layout() {
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<CompanyRail />
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
<div className="w-60 shrink-0 overflow-hidden">
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
companySidebar
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
@@ -354,21 +396,15 @@ export function Layout() {
|
||||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<CompanyRail />
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[width] duration-100 ease-out",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0">
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
companySidebar
|
||||
)}
|
||||
</div>
|
||||
</ResizableSidebarPane>
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
RoutineListRow,
|
||||
type RoutineListAgentSummary,
|
||||
type RoutineListProjectSummary,
|
||||
type RoutineListRowItem,
|
||||
} from "@/components/RoutineList";
|
||||
|
||||
export type ManagedRoutinesListAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
};
|
||||
|
||||
export type ManagedRoutinesListProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type ManagedRoutineMissingRef = {
|
||||
resourceKind: string;
|
||||
resourceKey: string;
|
||||
};
|
||||
|
||||
export type ManagedRoutinesListItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
routineId?: string | null;
|
||||
href?: string | null;
|
||||
resourceKey?: string | null;
|
||||
projectId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
cronExpression?: string | null;
|
||||
lastRunAt?: Date | string | null;
|
||||
lastRunStatus?: string | null;
|
||||
managedByPluginDisplayName?: string | null;
|
||||
missingRefs?: ManagedRoutineMissingRef[];
|
||||
};
|
||||
|
||||
export type ManagedRoutinesListProps = {
|
||||
routines: ManagedRoutinesListItem[];
|
||||
agents?: ManagedRoutinesListAgent[];
|
||||
projects?: ManagedRoutinesListProject[];
|
||||
pluginDisplayName?: string | null;
|
||||
emptyMessage?: string;
|
||||
runningRoutineKey?: string | null;
|
||||
statusMutationRoutineKey?: string | null;
|
||||
reconcilingRoutineKey?: string | null;
|
||||
resettingRoutineKey?: string | null;
|
||||
onRunNow?: (routine: ManagedRoutinesListItem) => void;
|
||||
onToggleEnabled?: (routine: ManagedRoutinesListItem, enabled: boolean) => void;
|
||||
onReconcile?: (routine: ManagedRoutinesListItem) => void;
|
||||
onReset?: (routine: ManagedRoutinesListItem) => void;
|
||||
};
|
||||
|
||||
function managedRoutineToRow(routine: ManagedRoutinesListItem): RoutineListRowItem {
|
||||
return {
|
||||
id: routine.key,
|
||||
title: routine.title,
|
||||
status: routine.status,
|
||||
projectId: routine.projectId ?? null,
|
||||
assigneeAgentId: routine.assigneeAgentId ?? null,
|
||||
lastRun: routine.lastRunAt || routine.lastRunStatus
|
||||
? {
|
||||
triggeredAt: routine.lastRunAt ?? null,
|
||||
status: routine.lastRunStatus ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function ManagedRoutinesList({
|
||||
routines,
|
||||
agents = [],
|
||||
projects = [],
|
||||
pluginDisplayName = null,
|
||||
emptyMessage = "No managed routines.",
|
||||
runningRoutineKey = null,
|
||||
statusMutationRoutineKey = null,
|
||||
reconcilingRoutineKey = null,
|
||||
resettingRoutineKey = null,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onReconcile,
|
||||
onReset,
|
||||
}: ManagedRoutinesListProps) {
|
||||
const agentById = new Map<string, RoutineListAgentSummary>(
|
||||
agents.map((agent) => [agent.id, { name: agent.name, icon: agent.icon }]),
|
||||
);
|
||||
const projectById = new Map<string, RoutineListProjectSummary>(
|
||||
projects.map((project) => [project.id, { name: project.name, color: project.color }]),
|
||||
);
|
||||
|
||||
if (routines.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border">
|
||||
{routines.map((routine) => {
|
||||
const row = managedRoutineToRow(routine);
|
||||
const href = routine.href ?? (routine.routineId ? `/routines/${routine.routineId}` : "/routines");
|
||||
const missingRefs = routine.missingRefs ?? [];
|
||||
const canUseRoutine = Boolean(routine.routineId && routine.resourceKey && missingRefs.length === 0);
|
||||
const managedBy = routine.managedByPluginDisplayName ?? pluginDisplayName;
|
||||
const hasRepairActions = Boolean(onReconcile || onReset);
|
||||
|
||||
return (
|
||||
<div key={routine.key} className="last:[&_a]:border-b-0">
|
||||
<RoutineListRow
|
||||
routine={row}
|
||||
projectById={projectById}
|
||||
agentById={agentById}
|
||||
runningRoutineId={runningRoutineKey}
|
||||
statusMutationRoutineId={statusMutationRoutineKey}
|
||||
href={href}
|
||||
configureLabel="Configure"
|
||||
managedByLabel={managedBy ? `Managed by ${managedBy}` : null}
|
||||
runNowButton
|
||||
hideArchiveAction
|
||||
disableRunNow={!canUseRoutine}
|
||||
disableToggle={!canUseRoutine}
|
||||
secondaryDetails={
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{routine.resourceKey ? <span>{routine.resourceKey}</span> : null}
|
||||
{routine.cronExpression ? <span>Schedule {routine.cronExpression}</span> : null}
|
||||
</span>
|
||||
}
|
||||
onRunNow={() => onRunNow?.(routine)}
|
||||
onToggleEnabled={() => onToggleEnabled?.(routine, row.status === "active")}
|
||||
/>
|
||||
{hasRepairActions ? (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 pb-3 text-xs text-muted-foreground last:border-b-0"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{missingRefs.length
|
||||
? `Missing ${missingRefs.map((ref) => `${ref.resourceKind}:${ref.resourceKey}`).join(", ")}`
|
||||
: "Routine defaults can be repaired."}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{onReconcile ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={reconcilingRoutineKey === routine.key}
|
||||
onClick={() => onReconcile(routine)}
|
||||
>
|
||||
{reconcilingRoutineKey === routine.key ? "Reconciling..." : "Reconcile"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onReset ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={resettingRoutineKey === routine.key}
|
||||
onClick={() => onReset(routine)}
|
||||
>
|
||||
{resettingRoutineKey === routine.key ? "Resetting..." : "Reset"}
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -33,7 +33,11 @@ vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
||||
function renderMarkdown(children: string, seededIssues: Array<{ identifier: string; status: string; title?: string }> = []) {
|
||||
function renderMarkdown(
|
||||
children: string,
|
||||
seededIssues: Array<{ identifier: string; status: string; title?: string }> = [],
|
||||
props: Partial<ComponentProps<typeof MarkdownBody>> = {},
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -54,7 +58,7 @@ function renderMarkdown(children: string, seededIssues: Array<{ identifier: stri
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{children}</MarkdownBody>
|
||||
<MarkdownBody {...props}>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -279,6 +283,64 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain('href="PAP-1271"');
|
||||
});
|
||||
|
||||
it("leaves wiki links as text unless explicitly enabled", () => {
|
||||
const html = renderMarkdown("See [[wiki/entities/paperclip]].");
|
||||
|
||||
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip.md"');
|
||||
});
|
||||
|
||||
it("renders wiki links with a custom resolver when enabled", () => {
|
||||
const html = renderMarkdown(
|
||||
"See [[wiki/entities/paperclip|Paperclip]] and [[wiki/entities/dotta-b]].",
|
||||
[],
|
||||
{
|
||||
enableWikiLinks: true,
|
||||
resolveWikiLinkHref: (target) => `/wiki/page/${target.endsWith(".md") ? target : `${target}.md`}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/wiki/page/wiki/entities/paperclip.md"');
|
||||
expect(html).toContain('data-paperclip-wiki-link="true"');
|
||||
expect(html).toContain('data-paperclip-wiki-target="wiki/entities/paperclip"');
|
||||
expect(html).toContain(">Paperclip</a>");
|
||||
expect(html).toContain('href="/wiki/page/wiki/entities/dotta-b.md"');
|
||||
expect(html).toContain(">wiki/entities/dotta-b</a>");
|
||||
expect(html).not.toContain("[[wiki/entities/paperclip");
|
||||
});
|
||||
|
||||
it("keeps wiki links as text when the custom resolver rejects them", () => {
|
||||
const html = renderMarkdown(
|
||||
"See [[wiki/entities/paperclip]].",
|
||||
[],
|
||||
{
|
||||
enableWikiLinks: true,
|
||||
wikiLinkRoot: "/wiki/page",
|
||||
resolveWikiLinkHref: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||
expect(html).not.toContain('data-paperclip-wiki-link="true"');
|
||||
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
|
||||
});
|
||||
|
||||
it("does not render wiki links inside code spans or code blocks", () => {
|
||||
const html = renderMarkdown(
|
||||
"Inline `[[wiki/entities/paperclip]]`.\n\n```md\n[[wiki/entities/dotta-b]]\n```",
|
||||
[],
|
||||
{
|
||||
enableWikiLinks: true,
|
||||
wikiLinkRoot: "/wiki/page",
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||
expect(html).toContain("[[wiki/entities/dotta-b]]");
|
||||
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
|
||||
expect(html).not.toContain('href="/wiki/page/wiki/entities/dotta-b"');
|
||||
});
|
||||
|
||||
it("applies wrap-friendly styles to long inline content", () => {
|
||||
const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken");
|
||||
|
||||
@@ -294,6 +356,20 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
|
||||
});
|
||||
|
||||
it("renders markdown tables in a horizontally scrollable region", () => {
|
||||
const html = renderMarkdown([
|
||||
"| Time UTC | Source | Finding | Stalled leaf | Escalation |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
"| 2026-04-30T14:31:35Z | PAP-2505 | in_review_without_action_path | PAP-2779 | PAP-2910 |",
|
||||
].join("\n"));
|
||||
|
||||
expect(html).toContain('class="paperclip-markdown-table-scroll"');
|
||||
expect(html).toContain('aria-label="Scrollable table"');
|
||||
expect(html).toContain('tabindex="0"');
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain('style="overflow-wrap:anywhere;word-break:normal"');
|
||||
});
|
||||
|
||||
it("opens external links in a new tab with safe rel attributes", () => {
|
||||
const html = renderMarkdown("[docs](https://example.com/docs)");
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ interface MarkdownBodyProps {
|
||||
style?: React.CSSProperties;
|
||||
softBreaks?: boolean;
|
||||
linkIssueReferences?: boolean;
|
||||
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
|
||||
enableWikiLinks?: boolean;
|
||||
/** Base href used for wikilinks when no resolver is supplied. */
|
||||
wikiLinkRoot?: string;
|
||||
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
@@ -78,6 +84,11 @@ const scrollableBlockStyle: React.CSSProperties = {
|
||||
overflowX: "auto",
|
||||
};
|
||||
|
||||
const tableCellWrapStyle: React.CSSProperties = {
|
||||
overflowWrap: "anywhere",
|
||||
wordBreak: "normal",
|
||||
};
|
||||
|
||||
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
return {
|
||||
...wrapAnywhereStyle,
|
||||
@@ -85,6 +96,13 @@ function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
};
|
||||
}
|
||||
|
||||
function mergeTableCellStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
return {
|
||||
...tableCellWrapStyle,
|
||||
...style,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
return {
|
||||
...scrollableBlockStyle,
|
||||
@@ -111,6 +129,160 @@ function safeMarkdownUrlTransform(url: string): string {
|
||||
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
type MarkdownAstNode = {
|
||||
type?: string;
|
||||
value?: string;
|
||||
children?: MarkdownAstNode[];
|
||||
url?: string;
|
||||
title?: string | null;
|
||||
data?: {
|
||||
hProperties?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type ParsedWikiLink = {
|
||||
target: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const WIKI_LINK_PATTERN = /\[\[([^\]\r\n]+)\]\]/g;
|
||||
const WIKI_LINK_SKIP_PARENT_TYPES = new Set([
|
||||
"definition",
|
||||
"image",
|
||||
"imageReference",
|
||||
"link",
|
||||
"linkReference",
|
||||
]);
|
||||
|
||||
function parseWikiLinkBody(body: string): ParsedWikiLink | null {
|
||||
const [rawTarget, ...rawLabelParts] = body.split("|");
|
||||
const target = rawTarget?.trim() ?? "";
|
||||
const label = rawLabelParts.length > 0 ? rawLabelParts.join("|").trim() : target;
|
||||
if (!target || target.includes("[") || target.includes("]")) return null;
|
||||
return {
|
||||
target,
|
||||
label: label || target,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeWikiLinkTarget(target: string): string | null {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null;
|
||||
|
||||
const hashIndex = trimmed.indexOf("#");
|
||||
const rawPath = (hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed)
|
||||
.trim()
|
||||
.replace(/^\/+/, "");
|
||||
if (
|
||||
!rawPath ||
|
||||
rawPath.includes("\\") ||
|
||||
rawPath.split("/").some((segment) => !segment || segment === "." || segment === "..")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedPath = rawPath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
||||
const rawHash = hashIndex >= 0 ? trimmed.slice(hashIndex + 1).trim() : "";
|
||||
return rawHash ? `${encodedPath}#${encodeURIComponent(rawHash)}` : encodedPath;
|
||||
}
|
||||
|
||||
function defaultWikiLinkHref(target: string, wikiLinkRoot?: string): string | null {
|
||||
const encodedTarget = encodeWikiLinkTarget(target);
|
||||
if (!encodedTarget) return null;
|
||||
const root = wikiLinkRoot?.trim().replace(/\/+$/, "") ?? "";
|
||||
return root ? `${root}/${encodedTarget}` : encodedTarget;
|
||||
}
|
||||
|
||||
function createWikiLinkNode(href: string, wikiLink: ParsedWikiLink): MarkdownAstNode {
|
||||
return {
|
||||
type: "link",
|
||||
url: href,
|
||||
title: null,
|
||||
data: {
|
||||
hProperties: {
|
||||
"data-paperclip-wiki-link": "true",
|
||||
"data-paperclip-wiki-target": wikiLink.target,
|
||||
},
|
||||
},
|
||||
children: [{ type: "text", value: wikiLink.label }],
|
||||
};
|
||||
}
|
||||
|
||||
function splitTextByWikiLinks(
|
||||
value: string,
|
||||
options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
},
|
||||
): MarkdownAstNode[] {
|
||||
const nodes: MarkdownAstNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of value.matchAll(WIKI_LINK_PATTERN)) {
|
||||
const raw = match[0] ?? "";
|
||||
const body = match[1] ?? "";
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex) {
|
||||
nodes.push({ type: "text", value: value.slice(lastIndex, start) });
|
||||
}
|
||||
|
||||
const wikiLink = parseWikiLinkBody(body);
|
||||
let resolvedHref: string | null = null;
|
||||
if (wikiLink) {
|
||||
if (options.resolveWikiLinkHref) {
|
||||
const customHref = options.resolveWikiLinkHref(wikiLink.target, wikiLink.label);
|
||||
resolvedHref = customHref === undefined
|
||||
? defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot)
|
||||
: customHref;
|
||||
} else {
|
||||
resolvedHref = defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot);
|
||||
}
|
||||
}
|
||||
|
||||
if (wikiLink && resolvedHref) {
|
||||
nodes.push(createWikiLinkNode(resolvedHref, wikiLink));
|
||||
} else {
|
||||
nodes.push({ type: "text", value: raw });
|
||||
}
|
||||
lastIndex = start + raw.length;
|
||||
}
|
||||
|
||||
if (lastIndex < value.length) {
|
||||
nodes.push({ type: "text", value: value.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function transformWikiLinkChildren(
|
||||
node: MarkdownAstNode,
|
||||
options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
},
|
||||
) {
|
||||
if (!node.children || WIKI_LINK_SKIP_PARENT_TYPES.has(node.type ?? "")) return;
|
||||
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type === "text" && typeof child.value === "string" && child.value.includes("[[")) {
|
||||
return splitTextByWikiLinks(child.value, options);
|
||||
}
|
||||
transformWikiLinkChildren(child, options);
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
function createRemarkWikiLinks(options: {
|
||||
wikiLinkRoot?: string;
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
}) {
|
||||
return function remarkWikiLinks() {
|
||||
return (tree: MarkdownAstNode) => {
|
||||
transformWikiLinkChildren(tree, options);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function isGitHubUrl(href: string | null | undefined): boolean {
|
||||
if (!href) return false;
|
||||
try {
|
||||
@@ -321,11 +493,17 @@ export function MarkdownBody({
|
||||
style,
|
||||
softBreaks = true,
|
||||
linkIssueReferences = true,
|
||||
enableWikiLinks = false,
|
||||
wikiLinkRoot,
|
||||
resolveWikiLinkHref,
|
||||
resolveImageSrc,
|
||||
onImageClick,
|
||||
}: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
}
|
||||
if (linkIssueReferences) {
|
||||
remarkPlugins.push(remarkLinkIssueReferences);
|
||||
}
|
||||
@@ -348,13 +526,20 @@ export function MarkdownBody({
|
||||
{blockquoteChildren}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ node: _node, style: tableStyle, children: tableChildren, ...tableProps }) => (
|
||||
<div className="paperclip-markdown-table-scroll" role="region" aria-label="Scrollable table" tabIndex={0}>
|
||||
<table {...tableProps} style={tableStyle as React.CSSProperties | undefined}>
|
||||
{tableChildren}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => (
|
||||
<td {...tableCellProps} style={mergeWrapStyle(tableCellStyle as React.CSSProperties | undefined)}>
|
||||
<td {...tableCellProps} style={mergeTableCellStyle(tableCellStyle as React.CSSProperties | undefined)}>
|
||||
{tableCellChildren}
|
||||
</td>
|
||||
),
|
||||
th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => (
|
||||
<th {...tableHeaderProps} style={mergeWrapStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
|
||||
<th {...tableHeaderProps} style={mergeTableCellStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
|
||||
{tableHeaderChildren}
|
||||
</th>
|
||||
),
|
||||
@@ -370,7 +555,22 @@ export function MarkdownBody({
|
||||
{codeChildren}
|
||||
</code>
|
||||
),
|
||||
a: ({ href, style: linkStyle, children: linkChildren }) => {
|
||||
a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => {
|
||||
const dataProps = anchorProps as Record<string, unknown>;
|
||||
const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true";
|
||||
if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
{...anchorProps}
|
||||
rel="noreferrer"
|
||||
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
|
||||
>
|
||||
{linkChildren}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
||||
if (issueRef) {
|
||||
return (
|
||||
|
||||
@@ -405,6 +405,122 @@ describe("NewIssueDialog", () => {
|
||||
goalId: "goal-1",
|
||||
projectId: "project-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
workMode: "standard",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("restores the planning mode from dialog defaults", async () => {
|
||||
dialogState.newIssueDefaults = {
|
||||
title: "Planned from defaults",
|
||||
workMode: "planning",
|
||||
};
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
const planningButton = container.querySelector('[data-issue-work-mode="planning"]');
|
||||
expect(planningButton?.className).toContain("bg-accent");
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Issue"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
await vi.waitFor(() => {
|
||||
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Planned from defaults",
|
||||
workMode: "planning",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("applies project and execution workspace defaults for normal new issues", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Alpha",
|
||||
description: null,
|
||||
archivedAt: null,
|
||||
color: "#445566",
|
||||
workspaces: [
|
||||
{
|
||||
id: "project-workspace-1",
|
||||
name: "Primary",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: "project-workspace-2",
|
||||
name: "Isolated checkout",
|
||||
isPrimary: false,
|
||||
},
|
||||
],
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
name: "PAP-100",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
branchName: "feature/pap-100",
|
||||
cwd: "/tmp/workspace-1",
|
||||
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
dialogState.newIssueDefaults = {
|
||||
title: "Follow-up issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-2",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
};
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("New issue");
|
||||
expect(container.textContent).not.toContain("New sub-issue");
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Reusing PAP-100");
|
||||
});
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Issue"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Follow-up issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-2",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -465,6 +581,45 @@ describe("NewIssueDialog", () => {
|
||||
expect.objectContaining({
|
||||
title: "Typed issue",
|
||||
description: "Typed description",
|
||||
workMode: "standard",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("submits planning work mode when planning is selected", async () => {
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null;
|
||||
expect(titleInput).not.toBeNull();
|
||||
await typeTextareaValue(titleInput!, "Plan this first");
|
||||
|
||||
const planningButton = container.querySelector('[data-issue-work-mode="planning"]');
|
||||
expect(planningButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
planningButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Issue"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
await vi.waitFor(() => {
|
||||
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Plan this first",
|
||||
workMode: "planning",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent, type RefObject } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { IssueWorkMode } from "@paperclipai/shared";
|
||||
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -43,6 +44,8 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
ClipboardList,
|
||||
Hammer,
|
||||
Minus,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
Calendar,
|
||||
Paperclip,
|
||||
FileText,
|
||||
Flag,
|
||||
Loader2,
|
||||
ListTree,
|
||||
X,
|
||||
@@ -86,6 +90,7 @@ interface IssueDraft {
|
||||
executionWorkspaceMode?: string;
|
||||
selectedExecutionWorkspaceId?: string;
|
||||
useIsolatedExecutionWorkspace?: boolean;
|
||||
workMode?: IssueWorkMode;
|
||||
}
|
||||
|
||||
type StagedIssueFile = {
|
||||
@@ -130,6 +135,19 @@ const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||
],
|
||||
} as const;
|
||||
|
||||
function isIssueWorkMode(value: unknown): value is IssueWorkMode {
|
||||
return value === "standard" || value === "planning";
|
||||
}
|
||||
|
||||
const ISSUE_WORK_MODE_OPTIONS: ReadonlyArray<{
|
||||
value: IssueWorkMode;
|
||||
label: string;
|
||||
icon: typeof Hammer;
|
||||
}> = [
|
||||
{ value: "standard", label: "Standard", icon: Hammer },
|
||||
{ value: "planning", label: "Planning", icon: ClipboardList },
|
||||
];
|
||||
|
||||
function loadDraft(): IssueDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
@@ -201,9 +219,19 @@ function formatFileSize(file: File) {
|
||||
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const statuses = [
|
||||
{ value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault },
|
||||
{ value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault },
|
||||
const statuses: ReadonlyArray<{ value: string; label: string; color: string; description?: string }> = [
|
||||
{
|
||||
value: "backlog",
|
||||
label: "Backlog",
|
||||
color: issueStatusText.backlog ?? issueStatusTextDefault,
|
||||
description: "Parked — assignee will not be woken",
|
||||
},
|
||||
{
|
||||
value: "todo",
|
||||
label: "Todo",
|
||||
color: issueStatusText.todo ?? issueStatusTextDefault,
|
||||
description: "Executable — assignee will be woken",
|
||||
},
|
||||
{ value: "in_progress", label: "In Progress", color: issueStatusText.in_progress ?? issueStatusTextDefault },
|
||||
{ value: "in_review", label: "In Review", color: issueStatusText.in_review ?? issueStatusTextDefault },
|
||||
{ value: "done", label: "Done", color: issueStatusText.done ?? issueStatusTextDefault },
|
||||
@@ -242,6 +270,21 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function defaultExecutionWorkspaceModeForIssueDefaults(
|
||||
defaults: {
|
||||
executionWorkspaceId?: unknown;
|
||||
executionWorkspaceMode?: unknown;
|
||||
},
|
||||
project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined,
|
||||
) {
|
||||
if (typeof defaults.executionWorkspaceId === "string" && defaults.executionWorkspaceId.length > 0) {
|
||||
return "reuse_existing";
|
||||
}
|
||||
return typeof defaults.executionWorkspaceMode === "string" && defaults.executionWorkspaceMode.length > 0
|
||||
? defaults.executionWorkspaceMode
|
||||
: defaultExecutionWorkspaceModeForProject(project);
|
||||
}
|
||||
|
||||
const IssueTitleTextarea = memo(function IssueTitleTextarea({
|
||||
value,
|
||||
pending,
|
||||
@@ -385,6 +428,7 @@ export function NewIssueDialog() {
|
||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||
const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState<string>("shared_workspace");
|
||||
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
|
||||
const [workMode, setWorkMode] = useState<IssueWorkMode>("standard");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
|
||||
@@ -404,6 +448,7 @@ export function NewIssueDialog() {
|
||||
// Popover states
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [workModeOpen, setWorkModeOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [companyOpen, setCompanyOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
@@ -611,6 +656,7 @@ export function NewIssueDialog() {
|
||||
assigneeChrome,
|
||||
executionWorkspaceMode,
|
||||
selectedExecutionWorkspaceId,
|
||||
workMode,
|
||||
});
|
||||
}, [
|
||||
newIssueOpen,
|
||||
@@ -627,6 +673,7 @@ export function NewIssueDialog() {
|
||||
assigneeChrome,
|
||||
executionWorkspaceMode,
|
||||
selectedExecutionWorkspaceId,
|
||||
workMode,
|
||||
]);
|
||||
|
||||
const handleTitleChange = useCallback((nextTitle: string) => {
|
||||
@@ -663,6 +710,7 @@ export function NewIssueDialog() {
|
||||
assigneeChrome,
|
||||
executionWorkspaceMode,
|
||||
selectedExecutionWorkspaceId,
|
||||
workMode,
|
||||
newIssueOpen,
|
||||
queueDraftSave,
|
||||
]);
|
||||
@@ -681,14 +729,13 @@ export function NewIssueDialog() {
|
||||
|
||||
const draft = loadDraft();
|
||||
if (newIssueDefaults.parentId) {
|
||||
const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard";
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
|
||||
?? defaultProjectWorkspaceIdForProject(defaultProject);
|
||||
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
||||
? "reuse_existing"
|
||||
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
const defaultExecutionWorkspaceMode = defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject);
|
||||
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
@@ -700,18 +747,21 @@ export function NewIssueDialog() {
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceMode);
|
||||
setWorkMode(nextWorkMode);
|
||||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || defaultProject
|
||||
? defaultProjectId || null
|
||||
: null;
|
||||
} else if (newIssueDefaults.title) {
|
||||
const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard";
|
||||
setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
@@ -720,12 +770,19 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
|
||||
setWorkMode(nextWorkMode);
|
||||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
|
||||
? defaultProjectId || null
|
||||
: null;
|
||||
} else if (draft && draft.title.trim()) {
|
||||
const nextWorkMode = isIssueWorkMode(draft.workMode) ? draft.workMode : "standard";
|
||||
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
||||
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
||||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||
const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined;
|
||||
const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined;
|
||||
setIssueText(draft.title, draft.description);
|
||||
setStatus(draft.status || "todo");
|
||||
setPriority(draft.priority);
|
||||
@@ -739,27 +796,42 @@ export function NewIssueDialog() {
|
||||
setShowReviewerRow(!!(draft.reviewerValue));
|
||||
setShowApproverRow(!!(draft.approverValue));
|
||||
setProjectId(restoredProjectId);
|
||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
||||
setProjectWorkspaceId(
|
||||
hasExplicitProjectWorkspaceId
|
||||
? (newIssueDefaults.projectWorkspaceId ?? "")
|
||||
: (draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)),
|
||||
);
|
||||
setAssigneeModelLane(draft.assigneeModelLane ?? "primary");
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
setExecutionWorkspaceMode(
|
||||
draft.executionWorkspaceMode
|
||||
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
|
||||
hasExplicitExecutionWorkspaceId || hasExplicitExecutionWorkspaceMode
|
||||
? defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, restoredProject)
|
||||
: (
|
||||
draft.executionWorkspaceMode
|
||||
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject))
|
||||
),
|
||||
);
|
||||
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = draft.projectWorkspaceId || restoredProject
|
||||
setWorkMode(nextWorkMode);
|
||||
setSelectedExecutionWorkspaceId(
|
||||
hasExplicitExecutionWorkspaceId
|
||||
? (newIssueDefaults.executionWorkspaceId ?? "")
|
||||
: (draft.selectedExecutionWorkspaceId ?? ""),
|
||||
);
|
||||
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || hasExplicitExecutionWorkspaceId || draft.projectWorkspaceId || restoredProject
|
||||
? restoredProjectId || null
|
||||
: null;
|
||||
} else {
|
||||
setWorkMode("standard");
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||
setIssueText("", "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
@@ -768,9 +840,11 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
|
||||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
|
||||
? defaultProjectId || null
|
||||
: null;
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects, selectedCompanyId, setIssueText]);
|
||||
|
||||
@@ -829,6 +903,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeChrome(false);
|
||||
setExecutionWorkspaceMode("shared_workspace");
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
setWorkMode("standard");
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setStagedFiles([]);
|
||||
@@ -855,6 +930,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeChrome(false);
|
||||
setExecutionWorkspaceMode("shared_workspace");
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
setWorkMode("standard");
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
@@ -905,6 +981,7 @@ export function NewIssueDialog() {
|
||||
description: currentDescription || undefined,
|
||||
status,
|
||||
priority: priority || "medium",
|
||||
workMode,
|
||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
||||
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
|
||||
@@ -1112,6 +1189,8 @@ export function NewIssueDialog() {
|
||||
},
|
||||
[assigneeAdapterModels],
|
||||
);
|
||||
const currentWorkMode = ISSUE_WORK_MODE_OPTIONS[workMode === "planning" ? 1 : 0]!;
|
||||
const CurrentWorkModeIcon = currentWorkMode.icon;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -1269,6 +1348,10 @@ export function NewIssueDialog() {
|
||||
trackRecentAssignee(nextAssignee.assigneeAgentId);
|
||||
}
|
||||
setAssigneeValue(value);
|
||||
const hasAssignee = Boolean(nextAssignee.assigneeAgentId || nextAssignee.assigneeUserId);
|
||||
if (hasAssignee && status === "backlog") {
|
||||
setStatus("todo");
|
||||
}
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (projectId) {
|
||||
@@ -1760,18 +1843,23 @@ export function NewIssueDialog() {
|
||||
{currentStatus.label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
<PopoverContent className="w-56 p-1" align="start">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
"flex w-full items-start gap-2 px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
s.value === status && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
|
||||
>
|
||||
<CircleDot className={cn("h-3 w-3", s.color)} />
|
||||
{s.label}
|
||||
<CircleDot className={cn("h-3 w-3 mt-0.5 shrink-0", s.color)} />
|
||||
<span className="flex flex-col text-left leading-tight">
|
||||
<span>{s.label}</span>
|
||||
{s.description ? (
|
||||
<span className="text-[10px] text-muted-foreground">{s.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
@@ -1834,6 +1922,48 @@ export function NewIssueDialog() {
|
||||
Upload
|
||||
</button>
|
||||
|
||||
{/* Work mode chip */}
|
||||
<Popover open={workModeOpen} onOpenChange={setWorkModeOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-issue-work-mode-chip={workMode}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
|
||||
workMode === "planning"
|
||||
? "border-amber-500/60 bg-amber-500/15 text-amber-800 hover:bg-amber-500/25 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<CurrentWorkModeIcon className="h-3 w-3" />
|
||||
{currentWorkMode.label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{ISSUE_WORK_MODE_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
data-issue-work-mode={option.value}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
option.value === workMode && "bg-accent",
|
||||
option.value === "planning" && "text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
onClick={() => {
|
||||
setWorkMode(option.value);
|
||||
setWorkModeOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* More (dates) */}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -1854,6 +1984,18 @@ export function NewIssueDialog() {
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{assigneeValue && status === "backlog" ? (
|
||||
<div
|
||||
data-testid="new-issue-assigned-backlog-note"
|
||||
className="mx-4 mb-2 flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
|
||||
>
|
||||
<Flag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<span className="leading-snug">
|
||||
Assigning implies executable intent — leave status as <span className="font-medium">Backlog</span> only to deliberately park this. The assignee will not be woken until status moves to <span className="font-medium">Todo</span> or <span className="font-medium">In Progress</span>.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border shrink-0">
|
||||
<Button
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local";
|
||||
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import {
|
||||
@@ -189,16 +190,13 @@ export function OnboardingWizard() {
|
||||
if (step === 3) autoResizeTextarea();
|
||||
}, [step, taskDescription, autoResizeTextarea]);
|
||||
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
isLoading: adapterModelsLoading,
|
||||
isFetching: adapterModelsFetching
|
||||
} = useQuery({
|
||||
const { data: adapterModels } = useQuery({
|
||||
// The wizard doesn't expose an environment selector, so models always
|
||||
// resolve against the local Paperclip host (environmentId = null).
|
||||
queryKey: createdCompanyId
|
||||
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
||||
: ["agents", "none", "adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||
? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null)
|
||||
: ["agents", "none", "adapter-models", adapterType, null],
|
||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }),
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
|
||||
});
|
||||
const getCapabilities = useAdapterCapabilities();
|
||||
@@ -329,8 +327,10 @@ export function OnboardingWizard() {
|
||||
: adapterType === "gemini_local"
|
||||
? model || DEFAULT_GEMINI_LOCAL_MODEL
|
||||
: adapterType === "cursor"
|
||||
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: model,
|
||||
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: adapterType === "opencode_local"
|
||||
? model || DEFAULT_OPENCODE_LOCAL_MODEL
|
||||
: model,
|
||||
command,
|
||||
args,
|
||||
url,
|
||||
@@ -427,36 +427,12 @@ export function OnboardingWizard() {
|
||||
setError(null);
|
||||
try {
|
||||
if (adapterType === "opencode_local") {
|
||||
const selectedModelId = model.trim();
|
||||
if (!selectedModelId) {
|
||||
if (!isValidOpenCodeModelId(model)) {
|
||||
setError(
|
||||
"OpenCode requires an explicit model in provider/model format."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (adapterModelsError) {
|
||||
setError(
|
||||
adapterModelsError instanceof Error
|
||||
? adapterModelsError.message
|
||||
: "Failed to load OpenCode models."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (adapterModelsLoading || adapterModelsFetching) {
|
||||
setError(
|
||||
"OpenCode models are still loading. Please wait and try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const discoveredModels = adapterModels ?? [];
|
||||
if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
|
||||
setError(
|
||||
discoveredModels.length === 0
|
||||
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||
: `Configured OpenCode model is unavailable: ${selectedModelId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalAdapter) {
|
||||
@@ -777,12 +753,17 @@ export function OnboardingWizard() {
|
||||
onClick={() => {
|
||||
const nextType = opt.type;
|
||||
setAdapterType(nextType);
|
||||
if (nextType === "codex_local" && !model) {
|
||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||
if (nextType === "codex_local") {
|
||||
if (!model) {
|
||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (nextType !== "codex_local") {
|
||||
setModel("");
|
||||
if (nextType === "opencode_local") {
|
||||
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||
return;
|
||||
}
|
||||
setModel("");
|
||||
}}
|
||||
>
|
||||
{opt.recommended && (
|
||||
@@ -839,9 +820,7 @@ export function OnboardingWizard() {
|
||||
return;
|
||||
}
|
||||
if (nextType === "opencode_local") {
|
||||
if (!model.includes("/")) {
|
||||
setModel("");
|
||||
}
|
||||
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||
return;
|
||||
}
|
||||
setModel("");
|
||||
|
||||
@@ -1,326 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode2,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { FileTree } from "./FileTree";
|
||||
import type { FileTreeProps } from "./FileTree";
|
||||
|
||||
// ── Tree types ────────────────────────────────────────────────────────
|
||||
|
||||
export type FileTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "dir" | "file";
|
||||
children: FileTreeNode[];
|
||||
/** Optional per-node metadata (e.g. import action) */
|
||||
action?: string | null;
|
||||
};
|
||||
|
||||
const TREE_BASE_INDENT = 16;
|
||||
const TREE_STEP_INDENT = 24;
|
||||
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export function buildFileTree(
|
||||
files: Record<string, unknown>,
|
||||
actionMap?: Map<string, string>,
|
||||
): FileTreeNode[] {
|
||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||
|
||||
for (const filePath of Object.keys(files)) {
|
||||
const segments = filePath.split("/").filter(Boolean);
|
||||
let current = root;
|
||||
let currentPath = "";
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const isLeaf = i === segments.length - 1;
|
||||
let next = current.children.find((c) => c.name === segment);
|
||||
if (!next) {
|
||||
next = {
|
||||
name: segment,
|
||||
path: currentPath,
|
||||
kind: isLeaf ? "file" : "dir",
|
||||
children: [],
|
||||
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
|
||||
};
|
||||
current.children.push(next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
function sortNode(node: FileTreeNode) {
|
||||
node.children.sort((a, b) => {
|
||||
// Files before directories so PROJECT.md appears above tasks/
|
||||
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
node.children.forEach(sortNode);
|
||||
}
|
||||
|
||||
sortNode(root);
|
||||
return root.children;
|
||||
export function PackageFileTree({ wrapLabels = false, ...props }: FileTreeProps) {
|
||||
return <FileTree {...props} wrapLabels={wrapLabels} />;
|
||||
}
|
||||
|
||||
export function countFiles(nodes: FileTreeNode[]): number {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "file") count++;
|
||||
else count += countFiles(node.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function collectAllPaths(
|
||||
nodes: FileTreeNode[],
|
||||
type: "file" | "dir" | "all" = "all",
|
||||
): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (type === "all" || node.kind === type) paths.add(node.path);
|
||||
for (const p of collectAllPaths(node.children, type)) paths.add(p);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function fileIcon(name: string) {
|
||||
if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
// ── Frontmatter helpers ───────────────────────────────────────────────
|
||||
|
||||
export type FrontmatterData = Record<string, string | string[]>;
|
||||
|
||||
export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const data: FrontmatterData = {};
|
||||
const rawYaml = match[1];
|
||||
const body = match[2];
|
||||
|
||||
let currentKey: string | null = null;
|
||||
let currentList: string[] | null = null;
|
||||
|
||||
for (const line of rawYaml.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.startsWith("- ") && currentKey) {
|
||||
if (!currentList) currentList = [];
|
||||
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentKey && currentList) {
|
||||
data[currentKey] = currentList;
|
||||
currentList = null;
|
||||
currentKey = null;
|
||||
}
|
||||
|
||||
const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
|
||||
if (kvMatch) {
|
||||
const key = kvMatch[1];
|
||||
const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
|
||||
if (val === "null") {
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
if (val) {
|
||||
data[key] = val;
|
||||
currentKey = null;
|
||||
} else {
|
||||
currentKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentKey && currentList) {
|
||||
data[currentKey] = currentList;
|
||||
}
|
||||
|
||||
return Object.keys(data).length > 0 ? { data, body } : null;
|
||||
}
|
||||
|
||||
export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
||||
name: "Name",
|
||||
title: "Title",
|
||||
kind: "Kind",
|
||||
reportsTo: "Reports to",
|
||||
skills: "Skills",
|
||||
status: "Status",
|
||||
description: "Description",
|
||||
priority: "Priority",
|
||||
assignee: "Assignee",
|
||||
project: "Project",
|
||||
recurring: "Recurring",
|
||||
targetDate: "Target date",
|
||||
};
|
||||
|
||||
// ── File tree component ───────────────────────────────────────────────
|
||||
|
||||
export function PackageFileTree({
|
||||
nodes,
|
||||
selectedFile,
|
||||
expandedDirs,
|
||||
checkedFiles,
|
||||
onToggleDir,
|
||||
onSelectFile,
|
||||
onToggleCheck,
|
||||
renderFileExtra,
|
||||
fileRowClassName,
|
||||
showCheckboxes = true,
|
||||
wrapLabels = false,
|
||||
depth = 0,
|
||||
}: {
|
||||
nodes: FileTreeNode[];
|
||||
selectedFile: string | null;
|
||||
expandedDirs: Set<string>;
|
||||
checkedFiles?: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
|
||||
/** Optional extra content rendered at the end of each file row (e.g. action badge) */
|
||||
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
|
||||
/** Optional additional className for file rows */
|
||||
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
|
||||
showCheckboxes?: boolean;
|
||||
/** Allow long file and directory names to wrap instead of forcing horizontal overflow. */
|
||||
wrapLabels?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{nodes.map((node) => {
|
||||
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
||||
if (node.kind === "dir") {
|
||||
const childFiles = collectAllPaths(node.children, "file");
|
||||
const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p));
|
||||
const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p));
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={cn(
|
||||
showCheckboxes
|
||||
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
||||
TREE_ROW_HEIGHT_CLASS,
|
||||
)}
|
||||
style={{
|
||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||
}}
|
||||
>
|
||||
{showCheckboxes && (
|
||||
<label className="flex items-center pl-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
|
||||
onChange={() => onToggleCheck?.(node.path, "dir")}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 py-1 text-left"
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{expanded ? (
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
||||
{node.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<PackageFileTree
|
||||
nodes={node.children}
|
||||
selectedFile={selectedFile}
|
||||
expandedDirs={expandedDirs}
|
||||
checkedFiles={effectiveCheckedFiles}
|
||||
onToggleDir={onToggleDir}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleCheck={onToggleCheck}
|
||||
renderFileExtra={renderFileExtra}
|
||||
fileRowClassName={fileRowClassName}
|
||||
showCheckboxes={showCheckboxes}
|
||||
wrapLabels={wrapLabels}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FileIcon = fileIcon(node.name);
|
||||
const checked = effectiveCheckedFiles.has(node.path);
|
||||
const extraClassName = fileRowClassName?.(node, checked);
|
||||
return (
|
||||
<div
|
||||
key={node.path}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
||||
TREE_ROW_HEIGHT_CLASS,
|
||||
node.path === selectedFile && "text-foreground bg-accent/20",
|
||||
extraClassName,
|
||||
)}
|
||||
style={{
|
||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||
}}
|
||||
onClick={() => onSelectFile(node.path)}
|
||||
>
|
||||
{showCheckboxes && (
|
||||
<label className="flex items-center pl-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggleCheck?.(node.path, "file")}
|
||||
className="mr-2 accent-foreground"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
|
||||
onClick={() => onSelectFile(node.path)}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<FileIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
||||
{node.name}
|
||||
</span>
|
||||
</button>
|
||||
{renderFileExtra?.(node, checked)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export {
|
||||
FRONTMATTER_FIELD_LABELS,
|
||||
buildFileTree,
|
||||
collectAllPaths,
|
||||
countFiles,
|
||||
parseFrontmatter,
|
||||
} from "./FileTree";
|
||||
export type {
|
||||
FileTreeBadge,
|
||||
FileTreeBadgeVariant,
|
||||
FileTreeEmptyState,
|
||||
FileTreeErrorState,
|
||||
FileTreeNode,
|
||||
FileTreeProps,
|
||||
FileTreeTone,
|
||||
FrontmatterData,
|
||||
} from "./FileTree";
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// @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 { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function pointerEvent(type: string, clientX: number) {
|
||||
const event = new MouseEvent(type, { bubbles: true, clientX });
|
||||
Object.defineProperty(event, "pointerId", { value: 1 });
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("ResizableSidebarPane", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
function pane() {
|
||||
return container.firstElementChild as HTMLDivElement;
|
||||
}
|
||||
|
||||
function handle() {
|
||||
return container.querySelector('[role="separator"]') as HTMLDivElement | null;
|
||||
}
|
||||
|
||||
it("uses a persisted width when open", () => {
|
||||
window.localStorage.setItem("test.sidebar.width", "320");
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(pane().style.width).toBe("320px");
|
||||
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
|
||||
});
|
||||
|
||||
it("resizes by dragging and persists the new width", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
const separator = handle();
|
||||
expect(separator).not.toBeNull();
|
||||
separator!.setPointerCapture = vi.fn();
|
||||
|
||||
act(() => {
|
||||
separator!.dispatchEvent(pointerEvent("pointerdown", 240));
|
||||
separator!.dispatchEvent(pointerEvent("pointermove", 320));
|
||||
separator!.dispatchEvent(pointerEvent("pointerup", 320));
|
||||
});
|
||||
|
||||
expect(pane().style.width).toBe("320px");
|
||||
expect(window.localStorage.getItem("test.sidebar.width")).toBe("320");
|
||||
});
|
||||
|
||||
it("supports keyboard resizing and clamps to the configured bounds", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
const separator = handle();
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("256px");
|
||||
expect(window.localStorage.getItem("test.sidebar.width")).toBe("256");
|
||||
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("208px");
|
||||
|
||||
act(() => {
|
||||
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
||||
});
|
||||
expect(pane().style.width).toBe("420px");
|
||||
});
|
||||
|
||||
it("can render without a resize handle", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ResizableSidebarPane open resizable={false}>
|
||||
<div>Sidebar</div>
|
||||
</ResizableSidebarPane>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(handle()).toBeNull();
|
||||
expect(pane().style.width).toBe("240px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 240;
|
||||
const MIN_SIDEBAR_WIDTH = 208;
|
||||
const MAX_SIDEBAR_WIDTH = 420;
|
||||
const SIDEBAR_WIDTH_STEP = 16;
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width));
|
||||
}
|
||||
|
||||
function readStoredSidebarWidth(storageKey: string) {
|
||||
if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
if (!stored) return DEFAULT_SIDEBAR_WIDTH;
|
||||
const parsed = Number.parseInt(stored, 10);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH;
|
||||
return clampSidebarWidth(parsed);
|
||||
} catch {
|
||||
return DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredSidebarWidth(storageKey: string, width: number) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(clampSidebarWidth(width)));
|
||||
} catch {
|
||||
// Storage can be unavailable in private contexts; resizing should still work.
|
||||
}
|
||||
}
|
||||
|
||||
type ResizableSidebarPaneProps = {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
resizable?: boolean;
|
||||
storageKey?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ResizableSidebarPane({
|
||||
children,
|
||||
open,
|
||||
resizable = false,
|
||||
storageKey = "paperclip.sidebar.width",
|
||||
className,
|
||||
}: ResizableSidebarPaneProps) {
|
||||
const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const widthRef = useRef(width);
|
||||
const dragState = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedWidth = readStoredSidebarWidth(storageKey);
|
||||
widthRef.current = storedWidth;
|
||||
setWidth(storedWidth);
|
||||
}, [storageKey]);
|
||||
|
||||
const visibleWidth = open ? width : 0;
|
||||
const paneStyle = useMemo(
|
||||
() => ({ width: `${visibleWidth}px` }),
|
||||
[visibleWidth],
|
||||
);
|
||||
|
||||
const commitWidth = useCallback(
|
||||
(nextWidth: number) => {
|
||||
const clamped = clampSidebarWidth(nextWidth);
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
writeStoredSidebarWidth(storageKey, clamped);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!open || !resizable) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragState.current = { startX: event.clientX, startWidth: widthRef.current };
|
||||
setIsResizing(true);
|
||||
},
|
||||
[open, resizable],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX;
|
||||
const clamped = clampSidebarWidth(nextWidth);
|
||||
widthRef.current = clamped;
|
||||
setWidth(clamped);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endResize = useCallback(() => {
|
||||
if (!dragState.current) return;
|
||||
|
||||
dragState.current = null;
|
||||
setIsResizing(false);
|
||||
writeStoredSidebarWidth(storageKey, widthRef.current);
|
||||
}, [storageKey]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!open || !resizable) return;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
commitWidth(width - SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
commitWidth(width + SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
commitWidth(MIN_SIDEBAR_WIDTH);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
commitWidth(MAX_SIDEBAR_WIDTH);
|
||||
}
|
||||
},
|
||||
[commitWidth, open, resizable, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
!isResizing && "transition-[width] duration-100 ease-out",
|
||||
className,
|
||||
)}
|
||||
style={paneStyle}
|
||||
>
|
||||
{children}
|
||||
{resizable && open ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_SIDEBAR_WIDTH}
|
||||
aria-valuemax={MAX_SIDEBAR_WIDTH}
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
|
||||
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
|
||||
"hover:before:bg-border focus-visible:before:bg-ring",
|
||||
isResizing && "before:bg-ring",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endResize}
|
||||
onPointerCancel={endResize}
|
||||
onLostPointerCapture={endResize}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
Routine,
|
||||
RoutineRevision,
|
||||
RoutineRevisionSnapshotV1,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RoutineHistoryTab } from "./RoutineHistoryTab";
|
||||
|
||||
const mockRoutinesApi = vi.hoisted(() => ({
|
||||
listRevisions: vi.fn(),
|
||||
restoreRevision: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/routines", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../api/routines");
|
||||
return {
|
||||
...actual,
|
||||
routinesApi: {
|
||||
...((actual as { routinesApi?: Record<string, unknown> }).routinesApi ?? {}),
|
||||
...mockRoutinesApi,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, onClick, type = "button", disabled, ...props }: ComponentProps<"button">) => (
|
||||
<button type={type} onClick={onClick} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/input", () => ({
|
||||
Input: (props: ComponentProps<"input">) => <input {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/label", () => ({
|
||||
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => (
|
||||
<label htmlFor={htmlFor}>{children}</label>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/skeleton", () => ({
|
||||
Skeleton: (props: ComponentProps<"div">) => <div data-testid="skeleton" {...props} />,
|
||||
}));
|
||||
|
||||
const toastSpy = vi.fn();
|
||||
vi.mock("../context/ToastContext", () => ({
|
||||
useToastActions: () => ({ pushToast: toastSpy }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotV1(overrides?: Partial<RoutineRevisionSnapshotV1["routine"]>): RoutineRevisionSnapshotV1 {
|
||||
return {
|
||||
version: 1,
|
||||
routine: {
|
||||
id: "routine-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Daily standup digest",
|
||||
description: "Summarize standup notes",
|
||||
assigneeAgentId: null,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
variables: [],
|
||||
...overrides,
|
||||
},
|
||||
triggers: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createRevision(overrides: Partial<RoutineRevision> = {}): RoutineRevision {
|
||||
return {
|
||||
id: overrides.id ?? "revision-1",
|
||||
companyId: "company-1",
|
||||
routineId: "routine-1",
|
||||
revisionNumber: overrides.revisionNumber ?? 1,
|
||||
title: "Daily standup digest",
|
||||
description: "Summarize standup notes",
|
||||
snapshot: overrides.snapshot ?? snapshotV1(),
|
||||
changeSummary: null,
|
||||
restoredFromRevisionId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-05-01T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRoutine(overrides: Partial<Routine> = {}): Routine {
|
||||
return {
|
||||
id: "routine-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Daily standup digest",
|
||||
description: "Summarize standup notes",
|
||||
assigneeAgentId: null,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
variables: [],
|
||||
latestRevisionId: "revision-2",
|
||||
latestRevisionNumber: 2,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-1",
|
||||
lastTriggeredAt: null,
|
||||
lastEnqueuedAt: null,
|
||||
createdAt: new Date("2026-05-01T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("RoutineHistoryTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.clearAllMocks();
|
||||
toastSpy.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
async function render(props: Partial<Parameters<typeof RoutineHistoryTab>[0]> = {}) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const routine = props.routine ?? createRoutine();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RoutineHistoryTab
|
||||
routine={routine}
|
||||
isEditDirty={false}
|
||||
dirtyFields={[]}
|
||||
onDiscardEdits={() => {}}
|
||||
onSaveEdits={() => {}}
|
||||
agents={new Map()}
|
||||
projects={new Map()}
|
||||
onRestoreSecretMaterials={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
return root;
|
||||
}
|
||||
|
||||
it("shows the empty state when only the bootstrap revision exists", async () => {
|
||||
mockRoutinesApi.listRevisions.mockResolvedValue([
|
||||
createRevision({ id: "revision-1", revisionNumber: 1 }),
|
||||
]);
|
||||
await render({
|
||||
routine: createRoutine({ latestRevisionId: "revision-1", latestRevisionNumber: 1 }),
|
||||
});
|
||||
expect(container.textContent).toContain("No edits yet");
|
||||
expect(container.textContent).toContain("Revision 1 is the only history");
|
||||
});
|
||||
|
||||
it("renders the revision list with current and historical pills", async () => {
|
||||
const current = createRevision({
|
||||
id: "revision-2",
|
||||
revisionNumber: 2,
|
||||
changeSummary: "Updated routine",
|
||||
});
|
||||
const old = createRevision({
|
||||
id: "revision-1",
|
||||
revisionNumber: 1,
|
||||
changeSummary: "Created routine",
|
||||
});
|
||||
mockRoutinesApi.listRevisions.mockResolvedValue([current, old]);
|
||||
await render();
|
||||
expect(container.textContent).toContain("rev 2");
|
||||
expect(container.textContent).toContain("rev 1");
|
||||
expect(container.textContent).toContain("Current");
|
||||
});
|
||||
|
||||
it("shows the historical-preview banner with append-only copy when previewing an old revision", async () => {
|
||||
const current = createRevision({
|
||||
id: "revision-2",
|
||||
revisionNumber: 2,
|
||||
changeSummary: "Updated routine",
|
||||
});
|
||||
const old = createRevision({
|
||||
id: "revision-1",
|
||||
revisionNumber: 1,
|
||||
snapshot: snapshotV1({ status: "paused" }),
|
||||
changeSummary: "Created routine",
|
||||
});
|
||||
mockRoutinesApi.listRevisions.mockResolvedValue([current, old]);
|
||||
await render();
|
||||
const oldRow = container.querySelector(
|
||||
"[data-testid='revision-row-1']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(oldRow).not.toBeNull();
|
||||
await act(async () => {
|
||||
oldRow?.click();
|
||||
});
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Viewing revision 1 (read-only)");
|
||||
expect(container.textContent).toContain(
|
||||
"Restoring this revision creates a new revision 3 with the same content. History stays append-only.",
|
||||
);
|
||||
expect(container.textContent).toContain("Status");
|
||||
expect(container.textContent).toContain("paused");
|
||||
expect(container.textContent).toContain("Restore as new revision");
|
||||
});
|
||||
|
||||
it("blocks historical preview and surfaces the conflict banner when local edits are dirty", async () => {
|
||||
const current = createRevision({ id: "revision-2", revisionNumber: 2 });
|
||||
const old = createRevision({ id: "revision-1", revisionNumber: 1 });
|
||||
mockRoutinesApi.listRevisions.mockResolvedValue([current, old]);
|
||||
await render({
|
||||
isEditDirty: true,
|
||||
dirtyFields: [{ key: "description", label: "the description" }],
|
||||
});
|
||||
expect(container.textContent).toContain("Unsaved routine edits");
|
||||
const oldRow = container.querySelector(
|
||||
"[data-testid='revision-row-1']",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(oldRow?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls restoreRevision and surfaces a success toast after confirming restore", async () => {
|
||||
const current = createRevision({ id: "revision-2", revisionNumber: 2 });
|
||||
const old = createRevision({ id: "revision-1", revisionNumber: 1 });
|
||||
mockRoutinesApi.listRevisions.mockResolvedValue([current, old]);
|
||||
mockRoutinesApi.restoreRevision.mockResolvedValue({
|
||||
routine: createRoutine({ latestRevisionId: "revision-3", latestRevisionNumber: 3 }),
|
||||
revision: createRevision({
|
||||
id: "revision-3",
|
||||
revisionNumber: 3,
|
||||
restoredFromRevisionId: "revision-1",
|
||||
}),
|
||||
restoredFromRevisionId: "revision-1",
|
||||
restoredFromRevisionNumber: 1,
|
||||
secretMaterials: [],
|
||||
});
|
||||
await render();
|
||||
const oldRow = container.querySelector(
|
||||
"[data-testid='revision-row-1']",
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
oldRow?.click();
|
||||
});
|
||||
await flush();
|
||||
const restoreButtons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent === "Restore as new revision",
|
||||
);
|
||||
expect(restoreButtons.length).toBeGreaterThan(0);
|
||||
await act(async () => {
|
||||
restoreButtons[0].click();
|
||||
});
|
||||
await flush();
|
||||
expect(container.querySelector("[data-testid='dialog']")).not.toBeNull();
|
||||
const confirmButtons = Array.from(container.querySelectorAll("button")).filter((b) =>
|
||||
(b.textContent ?? "").includes("Restore as revision 3"),
|
||||
);
|
||||
expect(confirmButtons.length).toBeGreaterThan(0);
|
||||
await act(async () => {
|
||||
confirmButtons[0].click();
|
||||
});
|
||||
await flush();
|
||||
expect(mockRoutinesApi.restoreRevision).toHaveBeenCalledWith(
|
||||
"routine-1",
|
||||
"revision-1",
|
||||
{ changeSummary: null },
|
||||
);
|
||||
expect(toastSpy).toHaveBeenCalled();
|
||||
const successCall = toastSpy.mock.calls.find(
|
||||
(call) => call[0]?.title === "Restored revision 1 as revision 3",
|
||||
);
|
||||
expect(successCall).toBeTruthy();
|
||||
});
|
||||
|
||||
it("invokes onRestored with the restore response so the editor can rehydrate (PAP-3588)", async () => {
|
||||
const current = createRevision({ id: "revision-2", revisionNumber: 2 });
|
||||
const old = createRevision({
|
||||
id: "revision-1",
|
||||
revisionNumber: 1,
|
||||
snapshot: snapshotV1({ description: "Original description" }),
|
||||
});
|
||||
mockRoutinesApi.listRevisions.mockResolvedValue([current, old]);
|
||||
const restoredRoutine = createRoutine({
|
||||
description: "Original description",
|
||||
latestRevisionId: "revision-3",
|
||||
latestRevisionNumber: 3,
|
||||
});
|
||||
mockRoutinesApi.restoreRevision.mockResolvedValue({
|
||||
routine: restoredRoutine,
|
||||
revision: createRevision({
|
||||
id: "revision-3",
|
||||
revisionNumber: 3,
|
||||
restoredFromRevisionId: "revision-1",
|
||||
}),
|
||||
restoredFromRevisionId: "revision-1",
|
||||
restoredFromRevisionNumber: 1,
|
||||
secretMaterials: [],
|
||||
});
|
||||
const onRestored = vi.fn();
|
||||
await render({ onRestored });
|
||||
const oldRow = container.querySelector(
|
||||
"[data-testid='revision-row-1']",
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
oldRow?.click();
|
||||
});
|
||||
await flush();
|
||||
const restoreButtons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent === "Restore as new revision",
|
||||
);
|
||||
await act(async () => {
|
||||
restoreButtons[0].click();
|
||||
});
|
||||
await flush();
|
||||
const confirmButtons = Array.from(container.querySelectorAll("button")).filter((b) =>
|
||||
(b.textContent ?? "").includes("Restore as revision 3"),
|
||||
);
|
||||
await act(async () => {
|
||||
confirmButtons[0].click();
|
||||
});
|
||||
await flush();
|
||||
expect(onRestored).toHaveBeenCalledTimes(1);
|
||||
const [response] = onRestored.mock.calls[0];
|
||||
expect(response.routine).toEqual(restoredRoutine);
|
||||
expect(response.revision.id).toBe("revision-3");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { MoreHorizontal, Play } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
|
||||
export type RoutineListProjectSummary = {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type RoutineListAgentSummary = {
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
};
|
||||
|
||||
export type RoutineListRowItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
lastRun?: {
|
||||
triggeredAt?: Date | string | null;
|
||||
status?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function formatLastRunTimestamp(value: Date | string | null | undefined) {
|
||||
if (!value) return "Never";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatRoutineRunStatus(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
export function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
||||
if (currentStatus === "archived" && enabled) return "active";
|
||||
return enabled ? "active" : "paused";
|
||||
}
|
||||
|
||||
export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
routine,
|
||||
projectById,
|
||||
agentById,
|
||||
runningRoutineId,
|
||||
statusMutationRoutineId,
|
||||
href,
|
||||
configureLabel = "Edit",
|
||||
managedByLabel,
|
||||
secondaryDetails,
|
||||
runNowButton = false,
|
||||
disableRunNow = false,
|
||||
disableToggle = false,
|
||||
hideArchiveAction = false,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onToggleArchived,
|
||||
}: {
|
||||
routine: TRoutine;
|
||||
projectById: Map<string, RoutineListProjectSummary>;
|
||||
agentById: Map<string, RoutineListAgentSummary>;
|
||||
runningRoutineId: string | null;
|
||||
statusMutationRoutineId: string | null;
|
||||
href: string;
|
||||
configureLabel?: string;
|
||||
managedByLabel?: string | null;
|
||||
secondaryDetails?: ReactNode;
|
||||
runNowButton?: boolean;
|
||||
disableRunNow?: boolean;
|
||||
disableToggle?: boolean;
|
||||
hideArchiveAction?: boolean;
|
||||
onRunNow: (routine: TRoutine) => void;
|
||||
onToggleEnabled: (routine: TRoutine, enabled: boolean) => void;
|
||||
onToggleArchived?: (routine: TRoutine) => void;
|
||||
}) {
|
||||
const enabled = routine.status === "active";
|
||||
const isArchived = routine.status === "archived";
|
||||
const isStatusPending = statusMutationRoutineId === routine.id;
|
||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
|
||||
const isDraft = !isArchived && !routine.assigneeAgentId;
|
||||
const runDisabled = runningRoutineId === routine.id || isArchived || disableRunNow;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||
{(isArchived || routine.status === "paused" || isDraft) ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
|
||||
</span>
|
||||
) : null}
|
||||
{managedByLabel ? (
|
||||
<span className="text-xs text-muted-foreground">{managedByLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||
/>
|
||||
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
|
||||
</span>
|
||||
<span>
|
||||
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
||||
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{secondaryDetails ? (
|
||||
<div className="text-xs text-muted-foreground">{secondaryDetails}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
|
||||
{runNowButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={runDisabled}
|
||||
onClick={() => onRunNow(routine)}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch
|
||||
size="lg"
|
||||
checked={enabled}
|
||||
onCheckedChange={() => onToggleEnabled(routine, enabled)}
|
||||
disabled={isStatusPending || isArchived || disableToggle}
|
||||
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||
/>
|
||||
<span className="w-12 text-xs text-muted-foreground">
|
||||
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>{configureLabel}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={runDisabled}
|
||||
onClick={() => onRunNow(routine)}
|
||||
>
|
||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleEnabled(routine, enabled)}
|
||||
disabled={isStatusPending || isArchived || disableToggle}
|
||||
>
|
||||
{enabled ? "Pause" : "Enable"}
|
||||
</DropdownMenuItem>
|
||||
{!hideArchiveAction && onToggleArchived ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleArchived(routine)}
|
||||
disabled={isStatusPending}
|
||||
>
|
||||
{routine.status === "archived" ? "Restore" : "Archive"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -224,6 +224,74 @@ describe("RoutineRunVariablesDialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the mobile dialog bounded with an internal form scroll region", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RoutineRunVariablesDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
companyId="company-1"
|
||||
projects={[createProject()]}
|
||||
agents={[createAgent()]}
|
||||
defaultProjectId="project-1"
|
||||
defaultAssigneeAgentId="agent-1"
|
||||
variables={[
|
||||
{
|
||||
name: "notes",
|
||||
label: "notes",
|
||||
type: "textarea",
|
||||
defaultValue: null,
|
||||
required: false,
|
||||
options: [],
|
||||
},
|
||||
]}
|
||||
isPending={false}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const dialogContent = Array.from(document.body.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"),
|
||||
);
|
||||
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
|
||||
expect(dialogContent?.className).toContain("overflow-hidden");
|
||||
|
||||
const notesInput = document.querySelector("textarea");
|
||||
const formScrollRegion = Array.from(document.body.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("overscroll-contain"),
|
||||
);
|
||||
expect(formScrollRegion?.className).toContain("min-h-0");
|
||||
expect(formScrollRegion?.className).toContain("flex-1");
|
||||
expect(formScrollRegion?.className).toContain("overflow-y-auto");
|
||||
expect(formScrollRegion?.contains(notesInput)).toBe(true);
|
||||
|
||||
const footer = Array.from(document.body.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("pb-[calc(1rem+env(safe-area-inset-bottom))]"),
|
||||
);
|
||||
expect(footer?.className).toContain("shrink-0");
|
||||
expect(footer?.contains(formScrollRegion ?? null)).toBe(false);
|
||||
expect(footer?.textContent).toContain("Run routine");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders workspaceBranch as a read-only selected workspace value", async () => {
|
||||
issueWorkspaceDraft = {
|
||||
executionWorkspaceId: "workspace-1",
|
||||
|
||||
@@ -335,8 +335,8 @@ export function RoutineRunVariablesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[min(calc(100dvh-2rem),42rem)]">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-6 pb-4 pr-12 pt-6">
|
||||
{routineName && (
|
||||
<p className="text-muted-foreground text-sm">{routineName}</p>
|
||||
)}
|
||||
@@ -346,7 +346,7 @@ export function RoutineRunVariablesDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Agent *</Label>
|
||||
@@ -520,7 +520,10 @@ export function RoutineRunVariablesDialog({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter showCloseButton={false}>
|
||||
<DialogFooter
|
||||
showCloseButton={false}
|
||||
className="shrink-0 border-t border-border/60 bg-background px-6 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4"
|
||||
>
|
||||
{!selection.assigneeAgentId ? (
|
||||
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
|
||||
) : missingRequired.length > 0 ? (
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, KeyRound, Loader2, Plus, X } from "lucide-react";
|
||||
import type { CompanySecret, SecretVersionSelector } from "@paperclipai/shared";
|
||||
import { secretsApi } from "../api/secrets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface SecretBindingValue {
|
||||
secretId: string;
|
||||
version?: SecretVersionSelector;
|
||||
}
|
||||
|
||||
interface SecretBindingPickerProps {
|
||||
value: SecretBindingValue | null;
|
||||
onChange: (next: SecretBindingValue | null) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
allowVersionSelector?: boolean;
|
||||
emptyHint?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Optional whitelist of secret statuses to show. Defaults to "active".
|
||||
* Pass null to disable the filter and show every secret in the company.
|
||||
*/
|
||||
statusFilter?: Array<CompanySecret["status"]> | null;
|
||||
}
|
||||
|
||||
const VERSION_LATEST: SecretVersionSelector = "latest";
|
||||
|
||||
function describeSecret(secret: CompanySecret): string {
|
||||
const provider = secret.provider.replaceAll("_", " ");
|
||||
if (secret.managedMode === "external_reference") {
|
||||
return `External · ${provider}`;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
function statusTone(status: CompanySecret["status"]): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "text-emerald-600 dark:text-emerald-400";
|
||||
case "disabled":
|
||||
return "text-amber-600 dark:text-amber-400";
|
||||
case "archived":
|
||||
return "text-muted-foreground";
|
||||
case "deleted":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
export function SecretBindingPicker({
|
||||
value,
|
||||
onChange,
|
||||
label = "Secret",
|
||||
placeholder = "Select secret",
|
||||
allowVersionSelector = true,
|
||||
emptyHint = "No matching secrets. Create one to bind it here.",
|
||||
className,
|
||||
disabled,
|
||||
statusFilter = ["active"],
|
||||
}: SecretBindingPickerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createValue, setCreateValue] = useState("");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const secretsQuery = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.secrets.list(selectedCompanyId)
|
||||
: ["secrets", "__disabled__"],
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const filteredSecrets = useMemo(() => {
|
||||
const all = secretsQuery.data ?? [];
|
||||
if (statusFilter === null) return all;
|
||||
return all.filter((secret) => statusFilter.includes(secret.status));
|
||||
}, [secretsQuery.data, statusFilter]);
|
||||
|
||||
const selectedSecret = useMemo(() => {
|
||||
if (!value) return null;
|
||||
return (secretsQuery.data ?? []).find((secret) => secret.id === value.secretId) ?? null;
|
||||
}, [secretsQuery.data, value]);
|
||||
|
||||
const selectedMissing = Boolean(value && !selectedSecret);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
secretsApi.create(selectedCompanyId!, {
|
||||
name: createName.trim(),
|
||||
value: createValue,
|
||||
description: createDescription.trim() || null,
|
||||
}),
|
||||
onSuccess: (created) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId!) });
|
||||
onChange({ secretId: created.id, version: VERSION_LATEST });
|
||||
setCreateOpen(false);
|
||||
setCreateName("");
|
||||
setCreateValue("");
|
||||
setCreateDescription("");
|
||||
setCreateError(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setCreateError(error instanceof Error ? error.message : "Failed to create secret");
|
||||
},
|
||||
});
|
||||
|
||||
const versionDisplay = (selector: SecretVersionSelector | undefined) => {
|
||||
if (selector === undefined || selector === VERSION_LATEST) return "latest";
|
||||
return `v${selector}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<div className="flex items-center justify-between text-xs font-medium text-foreground/80">
|
||||
<span>{label}</span>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
onClick={() => onChange(null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3" /> Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<select
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border border-border bg-background pl-7 pr-2 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60",
|
||||
selectedMissing && "border-destructive text-destructive",
|
||||
)}
|
||||
value={value?.secretId ?? ""}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
if (!next) {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
onChange({ secretId: next, version: value?.version ?? VERSION_LATEST });
|
||||
}}
|
||||
disabled={disabled || secretsQuery.isPending}
|
||||
>
|
||||
<option value="">{secretsQuery.isPending ? "Loading…" : placeholder}</option>
|
||||
{selectedMissing && value ? (
|
||||
<option value={value.secretId}>Missing secret ({value.secretId.slice(0, 8)}…)</option>
|
||||
) : null}
|
||||
{filteredSecrets.map((secret) => (
|
||||
<option key={secret.id} value={secret.id}>
|
||||
{secret.name} — {describeSecret(secret)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{allowVersionSelector ? (
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-xs outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={value?.version === undefined ? VERSION_LATEST : String(value.version)}
|
||||
onChange={(event) => {
|
||||
if (!value) return;
|
||||
const raw = event.target.value;
|
||||
const next: SecretVersionSelector = raw === VERSION_LATEST ? VERSION_LATEST : Number.parseInt(raw, 10);
|
||||
onChange({ ...value, version: next });
|
||||
}}
|
||||
disabled={disabled || !value || !selectedSecret}
|
||||
aria-label="Version"
|
||||
>
|
||||
<option value={VERSION_LATEST}>latest</option>
|
||||
{selectedSecret
|
||||
? Array.from({ length: Math.max(0, selectedSecret.latestVersion) }, (_, index) => {
|
||||
const version = selectedSecret.latestVersion - index;
|
||||
if (version <= 0) return null;
|
||||
return (
|
||||
<option key={version} value={version}>
|
||||
v{version}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</select>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={disabled || !selectedCompanyId}
|
||||
aria-label="Create secret"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedSecret ? (
|
||||
<p className={cn("text-[11px] text-muted-foreground", statusTone(selectedSecret.status))}>
|
||||
{selectedSecret.status !== "active" ? `Status: ${selectedSecret.status}. ` : null}
|
||||
Bound to {versionDisplay(value?.version)} · {selectedSecret.key}
|
||||
</p>
|
||||
) : selectedMissing ? (
|
||||
<p className="text-[11px] text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
The previously selected secret is no longer available. Pick another or remove the binding.
|
||||
</p>
|
||||
) : (filteredSecrets.length === 0 && !secretsQuery.isPending) ? (
|
||||
<p className="text-[11px] text-muted-foreground">{emptyHint}</p>
|
||||
) : null}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create new secret</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground/80" htmlFor="secret-name">Name</label>
|
||||
<Input
|
||||
id="secret-name"
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
placeholder="OPENAI_API_KEY"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground/80" htmlFor="secret-value">Value</label>
|
||||
<Textarea
|
||||
id="secret-value"
|
||||
value={createValue}
|
||||
onChange={(event) => setCreateValue(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Paste the secret value"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
The value is stored once and never re-displayed. Rotate to replace.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-foreground/80" htmlFor="secret-description">Description</label>
|
||||
<Input
|
||||
id="secret-description"
|
||||
value={createDescription}
|
||||
onChange={(event) => setCreateDescription(event.target.value)}
|
||||
placeholder="Optional notes (no values)"
|
||||
/>
|
||||
</div>
|
||||
{createError ? <p className="text-xs text-destructive">{createError}</p> : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!createName.trim() || !createValue || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Create & bind
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,24 @@ async function flushReact() {
|
||||
describe("Sidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
async function renderSidebar() {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
@@ -107,21 +125,23 @@ describe("Sidebar", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not flash the Workspaces link while experimental settings are loading", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
it("links the top search icon to the search page without showing Search in Work nav", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
const root = await renderSidebar();
|
||||
|
||||
const topSearchLink = container.querySelector('a[aria-label="Search"]');
|
||||
expect(topSearchLink?.getAttribute("href")).toBe("/search");
|
||||
const workLinks = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim());
|
||||
expect(workLinks).not.toContain("Search");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
root.unmount();
|
||||
});
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
it("does not flash the Workspaces link while experimental settings are loading", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).not.toContain("Workspaces");
|
||||
|
||||
@@ -132,19 +152,7 @@ describe("Sidebar", () => {
|
||||
|
||||
it("shows the Workspaces link when isolated workspaces are enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
const root = await renderSidebar();
|
||||
|
||||
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces");
|
||||
expect(link?.getAttribute("href")).toBe("/workspaces");
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
@@ -45,27 +46,27 @@ export function Sidebar() {
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
|
||||
function openSearch() {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
||||
}
|
||||
|
||||
const pluginContext = {
|
||||
companyId: selectedCompanyId,
|
||||
companyPrefix: selectedCompany?.issuePrefix ?? null,
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
<SidebarCompanyMenu />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={openSearch}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<NavLink to="/search">
|
||||
<Search className="h-4 w-4" />
|
||||
</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ describe("SidebarAccountMenu", () => {
|
||||
expect(document.body.textContent).toContain("Documentation");
|
||||
expect(document.body.textContent).toContain("Paperclip v1.2.3");
|
||||
expect(document.body.textContent).toContain("jane@example.com");
|
||||
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
||||
.toContain("w-[var(--radix-popover-trigger-width)]");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -160,7 +160,7 @@ export function SidebarAccountMenu({
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
||||
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-1rem)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
||||
>
|
||||
<div className="h-24 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]" />
|
||||
<div className="-mt-8 px-4 pb-4">
|
||||
|
||||
@@ -145,6 +145,34 @@ async function openAgentMenu(label = "Open actions for Alpha") {
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function openAgentsSectionMenu() {
|
||||
const trigger = document.body.querySelector('button[aria-label="Agents section actions"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function chooseSortMode(label: string) {
|
||||
const item = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]'))
|
||||
.find((element) => element.textContent?.includes(label));
|
||||
expect(item).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
item?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function agentLinkLabels(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll('a[href^="/agents/"]'))
|
||||
.map((anchor) => anchor.textContent?.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
describe("SidebarAgents", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
@@ -165,6 +193,7 @@ describe("SidebarAgents", () => {
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -177,10 +206,11 @@ describe("SidebarAgents", () => {
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows edit and pause actions for an active sidebar agent", async () => {
|
||||
async function renderSidebarAgents() {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
@@ -192,6 +222,97 @@ describe("SidebarAgents", () => {
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("keeps top mode in stored org-aware order", async () => {
|
||||
localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"]));
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
|
||||
expect(agentLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
});
|
||||
|
||||
it("uses the heading for section menu and the plus button for agent creation", async () => {
|
||||
await renderSidebarAgents();
|
||||
|
||||
const sectionMenuTrigger = container.querySelector('button[aria-label="Agents section actions"]');
|
||||
expect(sectionMenuTrigger?.textContent).toContain("Agents");
|
||||
expect(sectionMenuTrigger?.querySelector("svg")).toBeNull();
|
||||
|
||||
const newAgentButton = container.querySelector('button[aria-label="New agent"]');
|
||||
expect(newAgentButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
newAgentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(mockOpenNewAgent).toHaveBeenCalledTimes(1);
|
||||
|
||||
await openAgentsSectionMenu();
|
||||
|
||||
const newAgentItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("New agent"));
|
||||
expect(newAgentItem).toBeFalsy();
|
||||
const browseLink = Array.from(document.body.querySelectorAll("a"))
|
||||
.find((element) => element.textContent?.includes("Browse agents"));
|
||||
expect(browseLink?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("sorts alphabetically and persists the selected mode per company and user", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
await openAgentsSectionMenu();
|
||||
await chooseSortMode("Alphabetical");
|
||||
|
||||
expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
expect(localStorage.getItem("paperclip.agentSortMode:company-1:user-1")).toBe("alphabetical");
|
||||
});
|
||||
|
||||
it("sorts recent agents by heartbeat, updated time, and created time descending", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({
|
||||
id: "agent-a",
|
||||
name: "Alpha",
|
||||
urlKey: "alpha",
|
||||
lastHeartbeatAt: null,
|
||||
updatedAt: new Date("2026-01-20T00:00:00Z"),
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "agent-b",
|
||||
name: "Bravo",
|
||||
urlKey: "bravo",
|
||||
lastHeartbeatAt: new Date("2026-01-10T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
||||
createdAt: new Date("2026-01-02T00:00:00Z"),
|
||||
}),
|
||||
makeAgent({
|
||||
id: "agent-c",
|
||||
name: "Charlie",
|
||||
urlKey: "charlie",
|
||||
lastHeartbeatAt: null,
|
||||
updatedAt: new Date("2026-01-20T00:00:00Z"),
|
||||
createdAt: new Date("2026-01-03T00:00:00Z"),
|
||||
}),
|
||||
]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
await openAgentsSectionMenu();
|
||||
await chooseSortMode("Recent");
|
||||
|
||||
expect(agentLinkLabels(container)).toEqual(["Bravo", "Charlie", "Alpha"]);
|
||||
});
|
||||
|
||||
it("shows edit and pause actions for an active sidebar agent", async () => {
|
||||
await renderSidebarAgents();
|
||||
await openAgentMenu();
|
||||
|
||||
const editLink = Array.from(document.body.querySelectorAll("a"))
|
||||
@@ -216,17 +337,8 @@ describe("SidebarAgents", () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ status: "paused", pauseReason: "manual", pausedAt: new Date("2026-01-02T00:00:00Z") }),
|
||||
]);
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await renderSidebarAgents();
|
||||
await openAgentMenu();
|
||||
|
||||
const resumeItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
@@ -248,17 +360,8 @@ describe("SidebarAgents", () => {
|
||||
makeAgent({ id: "agent-2", name: "Beta", urlKey: "beta" }),
|
||||
]);
|
||||
mockAgentsApi.pause.mockImplementation(() => new Promise(() => {}));
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await renderSidebarAgents();
|
||||
await openAgentMenu();
|
||||
|
||||
const pauseItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
@@ -287,17 +390,8 @@ describe("SidebarAgents", () => {
|
||||
pausedAt: new Date("2026-01-02T00:00:00Z"),
|
||||
}),
|
||||
]);
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await renderSidebarAgents();
|
||||
await openAgentMenu();
|
||||
|
||||
const budgetPausedItem = Array.from(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
PauseCircle,
|
||||
Pencil,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
@@ -20,14 +20,18 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { useAgentOrder } from "../hooks/useAgentOrder";
|
||||
import {
|
||||
AGENT_SORT_MODE_UPDATED_EVENT,
|
||||
getAgentSortModeStorageKey,
|
||||
readAgentSortMode,
|
||||
type AgentSortModeUpdatedDetail,
|
||||
type AgentSidebarSortMode,
|
||||
writeAgentSortMode,
|
||||
} from "../lib/agent-order";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -37,6 +41,41 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
|
||||
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "alphabetical", label: "Alphabetical" },
|
||||
{ value: "recent", label: "Recent" },
|
||||
];
|
||||
|
||||
function agentTimestamp(agent: Agent, field: "lastHeartbeatAt" | "updatedAt" | "createdAt"): number {
|
||||
const raw = agent[field];
|
||||
if (!raw) return 0;
|
||||
const time = new Date(raw).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function sortAgents(agents: Agent[], sortMode: AgentSidebarSortMode): Agent[] {
|
||||
if (sortMode === "top") return agents;
|
||||
const sorted = [...agents];
|
||||
if (sortMode === "alphabetical") {
|
||||
sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
||||
return sorted;
|
||||
}
|
||||
sorted.sort((left, right) => {
|
||||
const heartbeatDiff = agentTimestamp(right, "lastHeartbeatAt") - agentTimestamp(left, "lastHeartbeatAt");
|
||||
if (heartbeatDiff !== 0) return heartbeatDiff;
|
||||
|
||||
const updatedDiff = agentTimestamp(right, "updatedAt") - agentTimestamp(left, "updatedAt");
|
||||
if (updatedDiff !== 0) return updatedDiff;
|
||||
|
||||
const createdDiff = agentTimestamp(right, "createdAt") - agentTimestamp(left, "createdAt");
|
||||
return createdDiff !== 0
|
||||
? createdDiff
|
||||
: left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function SidebarAgentItem({
|
||||
activeAgentId,
|
||||
activeTab,
|
||||
@@ -195,16 +234,69 @@ export function SidebarAgents() {
|
||||
return filtered;
|
||||
}, [agents]);
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const sortModeStorageKey = useMemo(() => {
|
||||
if (!selectedCompanyId) return null;
|
||||
return getAgentSortModeStorageKey(selectedCompanyId, currentUserId);
|
||||
}, [currentUserId, selectedCompanyId]);
|
||||
const [sortMode, setSortMode] = useState<AgentSidebarSortMode>(() => {
|
||||
if (!sortModeStorageKey) return "top";
|
||||
return readAgentSortMode(sortModeStorageKey);
|
||||
});
|
||||
const { orderedAgents } = useAgentOrder({
|
||||
agents: visibleAgents,
|
||||
companyId: selectedCompanyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
const sortedAgents = useMemo(
|
||||
() => sortAgents(orderedAgents, sortMode),
|
||||
[orderedAgents, sortMode],
|
||||
);
|
||||
|
||||
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
||||
const activeAgentId = agentMatch?.[1] ?? null;
|
||||
const activeTab = agentMatch?.[2] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortModeStorageKey) {
|
||||
setSortMode("top");
|
||||
return;
|
||||
}
|
||||
setSortMode(readAgentSortMode(sortModeStorageKey));
|
||||
}, [sortModeStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortModeStorageKey) return;
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== sortModeStorageKey) return;
|
||||
setSortMode(readAgentSortMode(sortModeStorageKey));
|
||||
};
|
||||
const onCustomEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<AgentSortModeUpdatedDetail>).detail;
|
||||
if (!detail || detail.storageKey !== sortModeStorageKey) return;
|
||||
setSortMode(detail.sortMode);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(AGENT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(AGENT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
|
||||
};
|
||||
}, [sortModeStorageKey]);
|
||||
|
||||
const persistSortMode = useCallback(
|
||||
(value: string) => {
|
||||
const nextSortMode: AgentSidebarSortMode =
|
||||
value === "alphabetical" || value === "recent" ? value : "top";
|
||||
setSortMode(nextSortMode);
|
||||
if (sortModeStorageKey) {
|
||||
writeAgentSortMode(sortModeStorageKey, nextSortMode);
|
||||
}
|
||||
},
|
||||
[sortModeStorageKey],
|
||||
);
|
||||
|
||||
const pauseResumeAgent = useMutation({
|
||||
mutationFn: ({ agent, action }: { agent: Agent; action: "pause" | "resume" }) =>
|
||||
action === "pause"
|
||||
@@ -252,53 +344,42 @@ export function SidebarAgents() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="group">
|
||||
<div className="flex items-center px-3 py-1.5">
|
||||
<CollapsibleTrigger className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3 w-3 text-muted-foreground/60 transition-transform opacity-0 group-hover:opacity-100",
|
||||
open && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60">
|
||||
Agents
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openNewAgent();
|
||||
}}
|
||||
className="flex items-center justify-center h-4 w-4 rounded text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
aria-label="New agent"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||
{orderedAgents.map((agent: Agent) => {
|
||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||
return (
|
||||
<SidebarAgentItem
|
||||
key={agent.id}
|
||||
activeAgentId={activeAgentId}
|
||||
activeTab={activeTab}
|
||||
agent={agent}
|
||||
disabled={pendingAgentIds.has(agent.id)}
|
||||
isMobile={isMobile}
|
||||
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
|
||||
runCount={runCount}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<SidebarSection
|
||||
label="Agents"
|
||||
collapsible={{ open, onOpenChange: setOpen }}
|
||||
headerAction={{
|
||||
ariaLabel: "New agent",
|
||||
icon: Plus,
|
||||
onClick: openNewAgent,
|
||||
}}
|
||||
menu={{
|
||||
ariaLabel: "Agents section actions",
|
||||
actions: [
|
||||
{ type: "item", label: "Browse agents", icon: Users, href: "/agents/all" },
|
||||
{ type: "separator" },
|
||||
],
|
||||
radioLabel: "Agent sort",
|
||||
radioChoices: AGENT_SORT_CHOICES,
|
||||
radioValue: sortMode,
|
||||
onRadioValueChange: persistSortMode,
|
||||
}}
|
||||
>
|
||||
{sortedAgents.map((agent: Agent) => {
|
||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||
return (
|
||||
<SidebarAgentItem
|
||||
key={agent.id}
|
||||
activeAgentId={activeAgentId}
|
||||
activeTab={activeTab}
|
||||
agent={agent}
|
||||
disabled={pendingAgentIds.has(agent.id)}
|
||||
isMobile={isMobile}
|
||||
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
|
||||
runCount={runCount}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,28 +14,80 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||
updateProfile: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockOpenOnboarding = vi.hoisted(() => vi.fn());
|
||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockLocation = vi.hoisted(() => ({ pathname: "/PAP/dashboard" }));
|
||||
const mockSidebarPreferencesApi = vi.hoisted(() => ({
|
||||
getCompanyOrder: vi.fn(),
|
||||
updateCompanyOrder: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sidebarPreferences", () => ({
|
||||
sidebarPreferencesApi: mockSidebarPreferencesApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
useLocation: () => mockLocation,
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [
|
||||
{
|
||||
id: "company-1",
|
||||
issuePrefix: "PAP",
|
||||
name: "Acme Labs",
|
||||
brandColor: "#3366ff",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "company-2",
|
||||
issuePrefix: "STR",
|
||||
name: "Strata",
|
||||
brandColor: "#36a269",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "company-3",
|
||||
issuePrefix: "ANA",
|
||||
name: "Anachronist Wiki",
|
||||
brandColor: "#a36a21",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
selectedCompany: {
|
||||
id: "company-1",
|
||||
issuePrefix: "PAP",
|
||||
name: "Acme Labs",
|
||||
brandColor: "#3366ff",
|
||||
status: "active",
|
||||
},
|
||||
setSelectedCompanyId: mockSetSelectedCompanyId,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/DialogContext", () => ({
|
||||
useDialogActions: () => ({
|
||||
openOnboarding: mockOpenOnboarding,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./CompanyPatternIcon", () => ({
|
||||
CompanyPatternIcon: ({ companyName }: { companyName: string }) => (
|
||||
<span aria-hidden="true">{companyName.slice(0, 1)}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
@@ -68,6 +120,15 @@ describe("SidebarCompanyMenu", () => {
|
||||
},
|
||||
});
|
||||
mockAuthApi.signOut.mockResolvedValue(undefined);
|
||||
mockSidebarPreferencesApi.getCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ["company-1", "company-2", "company-3"],
|
||||
updatedAt: null,
|
||||
});
|
||||
mockSidebarPreferencesApi.updateCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ["company-1", "company-2", "company-3"],
|
||||
updatedAt: null,
|
||||
});
|
||||
mockLocation.pathname = "/PAP/dashboard";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -94,7 +155,7 @@ describe("SidebarCompanyMenu", () => {
|
||||
|
||||
expect(container.textContent).toContain("Acme Labs");
|
||||
|
||||
const trigger = container.querySelector('button[aria-label="Open Acme Labs menu"]');
|
||||
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
@@ -103,6 +164,11 @@ describe("SidebarCompanyMenu", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Switch workspace");
|
||||
expect(document.body.textContent).toContain("Edit");
|
||||
expect(document.body.textContent).toContain("Strata");
|
||||
expect(document.body.textContent).toContain("ANA");
|
||||
expect(document.body.textContent).toContain("Add company...");
|
||||
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
||||
expect(document.body.textContent).toContain("Company settings");
|
||||
expect(document.body.textContent).toContain("Sign out");
|
||||
@@ -122,4 +188,103 @@ describe("SidebarCompanyMenu", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles company order editing without selecting a workspace", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCompanyMenu />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const editButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((element) => element.textContent === "Edit");
|
||||
expect(editButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Done");
|
||||
expect(document.body.textContent).not.toContain("PAP");
|
||||
expect(document.body.textContent).not.toContain("ANA");
|
||||
expect(document.body.querySelector('button[aria-label="Reorder Strata"]')).toBeTruthy();
|
||||
|
||||
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Strata"));
|
||||
expect(strataItem).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to the selected workspace dashboard from company-prefixed routes", async () => {
|
||||
mockLocation.pathname = "/PAP/issues";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCompanyMenu />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Strata"));
|
||||
expect(strataItem).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-2");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/STR/dashboard");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, LogOut, Settings, UserPlus } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
GripVertical,
|
||||
LogOut,
|
||||
Plus,
|
||||
Settings,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -13,26 +33,134 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||
|
||||
interface SidebarCompanyMenuProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function WorkspaceIcon({ company }: { company: Company }) {
|
||||
return (
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
brandColor={company.brandColor}
|
||||
className="size-5 shrink-0 rounded-md text-[11px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isEditing,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
company: Company;
|
||||
isEditing: boolean;
|
||||
isSelected: boolean;
|
||||
onSelect: (company: Company) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: company.id, disabled: !isEditing });
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}}
|
||||
onSelect={(event) => {
|
||||
if (isEditing) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onSelect(company);
|
||||
}}
|
||||
className={cn(
|
||||
"min-w-0 gap-2 py-2",
|
||||
isEditing && "cursor-grab",
|
||||
isDragging && "opacity-80",
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<WorkspaceIcon company={company} />
|
||||
<span className="min-w-0 flex-1 truncate">{company.name}</span>
|
||||
{isEditing ? (
|
||||
<button
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
aria-label={`Reorder ${company.name}`}
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{company.issuePrefix}
|
||||
</span>
|
||||
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isEditingOrder, setIsEditingOrder] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompany } = useCompany();
|
||||
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = onOpenChange ?? setInternalOpen;
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 180, tolerance: 6 },
|
||||
}),
|
||||
);
|
||||
const sidebarCompanies = useMemo(
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
||||
companies: sidebarCompanies,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
@@ -43,49 +171,151 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||
},
|
||||
});
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) setIsEditingOrder(false);
|
||||
setOpen(nextOpen);
|
||||
}
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
setIsEditingOrder(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}
|
||||
|
||||
function selectCompany(company: Company) {
|
||||
const pathPrefix = location.pathname.split("/")[1]?.toUpperCase();
|
||||
const isCompanyRoute = sidebarCompanies.some((sidebarCompany) => (
|
||||
sidebarCompany.issuePrefix.toUpperCase() === pathPrefix
|
||||
));
|
||||
const shouldLeaveCurrentRoute = company.id !== selectedCompany?.id
|
||||
&& (location.pathname.startsWith("/instance/") || isCompanyRoute);
|
||||
|
||||
setSelectedCompanyId(company.id);
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
if (shouldLeaveCurrentRoute) {
|
||||
navigate(`/${company.issuePrefix}/dashboard`);
|
||||
}
|
||||
}
|
||||
|
||||
function addCompany() {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
openOnboarding();
|
||||
}
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const ids = orderedCompanies.map((company) => company.id);
|
||||
const oldIndex = ids.indexOf(active.id as string);
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||
},
|
||||
[orderedCompanies, persistOrder],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto flex-1 justify-start gap-1 px-2 py-1.5 text-left"
|
||||
aria-label={selectedCompany ? `Open ${selectedCompany.name} menu` : "Open company menu"}
|
||||
disabled={!selectedCompany}
|
||||
className="h-9 flex-1 justify-start gap-2 px-2 text-left"
|
||||
aria-label={selectedCompany ? `Open ${selectedCompany.name} workspace switcher` : "Open workspace switcher"}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{selectedCompany?.brandColor ? (
|
||||
<span
|
||||
className="size-4 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: selectedCompany.brandColor }}
|
||||
/>
|
||||
) : null}
|
||||
{selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
||||
<span className="truncate text-sm font-bold text-foreground">
|
||||
{selectedCompany?.name ?? "Select company"}
|
||||
{selectedCompany?.name ?? "Select workspace"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="truncate">
|
||||
{selectedCompany?.name ?? "Company"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<DropdownMenuLabel className="p-0 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
Switch workspace
|
||||
</DropdownMenuLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditingOrder((current) => !current);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{isEditingOrder ? "Done" : "Edit"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((company) => company.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isEditing={isEditingOrder}
|
||||
isSelected={company.id === selectedCompany?.id}
|
||||
onSelect={selectCompany}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{orderedCompanies.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No workspaces</DropdownMenuItem>
|
||||
) : null}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings/invites" onClick={closeNavigationChrome}>
|
||||
<DropdownMenuItem
|
||||
onClick={addCompany}
|
||||
className="gap-2 py-2 text-muted-foreground"
|
||||
disabled={isEditingOrder}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<span>Add company...</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings/invites"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{selectedCompany ? `Invite people to ${selectedCompany.name}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings" onClick={closeNavigationChrome}>
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
<span>Company settings</span>
|
||||
</Link>
|
||||
@@ -96,7 +326,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={signOutMutation.isPending}
|
||||
disabled={isEditingOrder || signOutMutation.isPending}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// @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 { Project } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
|
||||
const mockProjectsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOpenNewProject = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockPersistOrder = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
NavLink: ({
|
||||
children,
|
||||
className,
|
||||
to,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string | ((state: { isActive: boolean }) => string);
|
||||
to: string;
|
||||
}) => (
|
||||
<a
|
||||
href={to}
|
||||
className={typeof className === "function" ? className({ isActive: false }) : className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/PAP/projects/bravo/issues", search: "", hash: "", state: null }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewProject: mockOpenNewProject,
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewProject: mockOpenNewProject,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../api/projects", () => ({
|
||||
projectsApi: mockProjectsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useProjectOrder", () => ({
|
||||
useProjectOrder: ({ projects }: { projects: Project[] }) => {
|
||||
const curatedOrder = ["project-b", "project-a", "project-c"];
|
||||
return {
|
||||
orderedProjects: [...projects].sort(
|
||||
(left, right) => curatedOrder.indexOf(left.id) - curatedOrder.indexOf(right.id),
|
||||
),
|
||||
persistOrder: mockPersistOrder,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: () => ({
|
||||
slots: [{ id: "slot-1", pluginKey: "plugin-1" }],
|
||||
}),
|
||||
PluginSlotMount: ({ context }: { context: { projectId: string } }) => (
|
||||
<div data-testid={`project-slot-${context.projectId}`}>Plugin slot</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
if (!globalThis.PointerEvent) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).PointerEvent = MouseEvent;
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<Project>): Project {
|
||||
return {
|
||||
id: "project-a",
|
||||
companyId: "company-1",
|
||||
urlKey: "alpha",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Alpha",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#ef4444",
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/project-a",
|
||||
effectiveLocalFolder: "/tmp/project-a",
|
||||
origin: "local_folder",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
managedByPlugin: null,
|
||||
archivedAt: 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));
|
||||
});
|
||||
}
|
||||
|
||||
function projectLinkLabels(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll('a[href$="/issues"]'))
|
||||
.map((anchor) => anchor.textContent?.replace("Plugin slot", "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function openProjectsMenu(container: HTMLElement) {
|
||||
const trigger = container.querySelector('button[aria-label="Projects section actions"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function chooseSortMode(label: string) {
|
||||
const item = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]'))
|
||||
.find((element) => element.textContent?.includes(label));
|
||||
expect(item).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
item?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
describe("SidebarProjects", () => {
|
||||
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 }, mutations: { retry: false } },
|
||||
});
|
||||
localStorage.clear();
|
||||
mockProjectsApi.list.mockResolvedValue([
|
||||
makeProject({
|
||||
id: "project-a",
|
||||
urlKey: "alpha",
|
||||
name: "Alpha",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-05T00:00:00Z"),
|
||||
}),
|
||||
makeProject({
|
||||
id: "project-b",
|
||||
urlKey: "bravo",
|
||||
name: "Bravo",
|
||||
createdAt: new Date("2026-01-02T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-10T00:00:00Z"),
|
||||
}),
|
||||
makeProject({
|
||||
id: "project-c",
|
||||
urlKey: "charlie",
|
||||
name: "Charlie",
|
||||
createdAt: new Date("2026-01-03T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-12T00:00:00Z"),
|
||||
}),
|
||||
]);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const currentRoot = root;
|
||||
if (currentRoot) {
|
||||
await act(async () => {
|
||||
currentRoot.unmount();
|
||||
});
|
||||
}
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderSidebarProjects() {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarProjects />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("keeps top mode in curated order and renders plugin project slots", async () => {
|
||||
await renderSidebarProjects();
|
||||
|
||||
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the heading for section menu and the plus button for project creation", async () => {
|
||||
await renderSidebarProjects();
|
||||
|
||||
const sectionMenuTrigger = container.querySelector('button[aria-label="Projects section actions"]');
|
||||
expect(sectionMenuTrigger?.textContent).toContain("Projects");
|
||||
expect(sectionMenuTrigger?.querySelector("svg")).toBeNull();
|
||||
|
||||
const newProjectButton = container.querySelector('button[aria-label="New project"]');
|
||||
expect(newProjectButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
newProjectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(mockOpenNewProject).toHaveBeenCalledTimes(1);
|
||||
|
||||
await openProjectsMenu(container);
|
||||
|
||||
const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("New project"));
|
||||
expect(newProjectItem).toBeFalsy();
|
||||
const browseLink = Array.from(document.body.querySelectorAll("a"))
|
||||
.find((element) => element.textContent?.includes("Browse projects"));
|
||||
expect(browseLink?.getAttribute("href")).toBe("/projects");
|
||||
});
|
||||
|
||||
it("sorts alphabetically and persists the selected mode per company and user", async () => {
|
||||
await renderSidebarProjects();
|
||||
await openProjectsMenu(container);
|
||||
await chooseSortMode("Alphabetical");
|
||||
|
||||
expect(projectLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
expect(localStorage.getItem("paperclip.projectSortMode:company-1:user-1")).toBe("alphabetical");
|
||||
});
|
||||
|
||||
it("sorts recent projects by updated time descending", async () => {
|
||||
await renderSidebarProjects();
|
||||
await openProjectsMenu(container);
|
||||
await chooseSortMode("Recent");
|
||||
|
||||
expect(projectLinkLabels(container)).toEqual(["Charlie", "Bravo", "Alpha"]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NavLink, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import { FolderOpen, Plus } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
MouseSensor,
|
||||
@@ -22,25 +22,27 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
import {
|
||||
getProjectSortModeStorageKey,
|
||||
PROJECT_SORT_MODE_UPDATED_EVENT,
|
||||
readProjectSortMode,
|
||||
type ProjectSortModeUpdatedDetail,
|
||||
type ProjectSidebarSortMode,
|
||||
writeProjectSortMode,
|
||||
} from "../lib/project-order";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
|
||||
type ProjectSidebarSlot = ReturnType<typeof usePluginSlots>["slots"][number];
|
||||
|
||||
function SortableProjectItem({
|
||||
activeProjectRef,
|
||||
companyId,
|
||||
companyPrefix,
|
||||
isMobile,
|
||||
project,
|
||||
projectSidebarSlots,
|
||||
setSidebarOpen,
|
||||
}: {
|
||||
const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "alphabetical", label: "Alphabetical" },
|
||||
{ value: "recent", label: "Recent" },
|
||||
];
|
||||
|
||||
type ProjectItemProps = {
|
||||
activeProjectRef: string | null;
|
||||
companyId: string | null;
|
||||
companyPrefix: string | null;
|
||||
@@ -48,7 +50,92 @@ function SortableProjectItem({
|
||||
project: Project;
|
||||
projectSidebarSlots: ProjectSidebarSlot[];
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}) {
|
||||
isDragging?: boolean;
|
||||
};
|
||||
|
||||
function projectTimestamp(project: Project): number {
|
||||
const updated = new Date(project.updatedAt).getTime();
|
||||
if (Number.isFinite(updated)) return updated;
|
||||
const created = new Date(project.createdAt).getTime();
|
||||
return Number.isFinite(created) ? created : 0;
|
||||
}
|
||||
|
||||
function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Project[] {
|
||||
if (sortMode === "top") return projects;
|
||||
const sorted = [...projects];
|
||||
if (sortMode === "alphabetical") {
|
||||
sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
||||
return sorted;
|
||||
}
|
||||
sorted.sort((left, right) => {
|
||||
const timeDiff = projectTimestamp(right) - projectTimestamp(left);
|
||||
return timeDiff !== 0 ? timeDiff : left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function ProjectItem({
|
||||
activeProjectRef,
|
||||
companyId,
|
||||
companyPrefix,
|
||||
isMobile,
|
||||
project,
|
||||
projectSidebarSlots,
|
||||
setSidebarOpen,
|
||||
isDragging = false,
|
||||
}: ProjectItemProps) {
|
||||
const routeRef = projectRouteRef(project);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
activeProjectRef === routeRef || activeProjectRef === project.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
||||
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
{projectSidebarSlots.length > 0 && (
|
||||
<div className="ml-5 flex flex-col gap-0.5">
|
||||
{projectSidebarSlots.map((slot) => (
|
||||
<PluginSlotMount
|
||||
key={`${project.id}:${slot.pluginKey}:${slot.id}`}
|
||||
slot={slot}
|
||||
context={{
|
||||
companyId,
|
||||
companyPrefix,
|
||||
projectId: project.id,
|
||||
projectRef: routeRef,
|
||||
entityId: project.id,
|
||||
entityType: "project",
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableProjectItem(props: ProjectItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -56,9 +143,7 @@ function SortableProjectItem({
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: project.id });
|
||||
|
||||
const routeRef = projectRouteRef(project);
|
||||
} = useSortable({ id: props.project.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -72,51 +157,7 @@ function SortableProjectItem({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
activeProjectRef === routeRef || activeProjectRef === project.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
||||
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
{projectSidebarSlots.length > 0 && (
|
||||
<div className="ml-5 flex flex-col gap-0.5">
|
||||
{projectSidebarSlots.map((slot) => (
|
||||
<PluginSlotMount
|
||||
key={`${project.id}:${slot.pluginKey}:${slot.id}`}
|
||||
slot={slot}
|
||||
context={{
|
||||
companyId,
|
||||
companyPrefix,
|
||||
projectId: project.id,
|
||||
projectRef: routeRef,
|
||||
entityId: project.id,
|
||||
entityType: "project",
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProjectItem {...props} isDragging={isDragging} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -145,6 +186,14 @@ export function SidebarProjects() {
|
||||
});
|
||||
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const sortModeStorageKey = useMemo(() => {
|
||||
if (!selectedCompanyId) return null;
|
||||
return getProjectSortModeStorageKey(selectedCompanyId, currentUserId);
|
||||
}, [currentUserId, selectedCompanyId]);
|
||||
const [sortMode, setSortMode] = useState<ProjectSidebarSortMode>(() => {
|
||||
if (!sortModeStorageKey) return "top";
|
||||
return readProjectSortMode(sortModeStorageKey);
|
||||
});
|
||||
|
||||
const visibleProjects = useMemo(
|
||||
() => (projects ?? []).filter((project: Project) => !project.archivedAt),
|
||||
@@ -155,6 +204,11 @@ export function SidebarProjects() {
|
||||
companyId: selectedCompanyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
const sortedProjects = useMemo(
|
||||
() => sortProjects(orderedProjects, sortMode),
|
||||
[orderedProjects, sortMode],
|
||||
);
|
||||
const isTopMode = sortMode === "top";
|
||||
|
||||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||
@@ -165,8 +219,50 @@ export function SidebarProjects() {
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortModeStorageKey) {
|
||||
setSortMode("top");
|
||||
return;
|
||||
}
|
||||
setSortMode(readProjectSortMode(sortModeStorageKey));
|
||||
}, [sortModeStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortModeStorageKey) return;
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== sortModeStorageKey) return;
|
||||
setSortMode(readProjectSortMode(sortModeStorageKey));
|
||||
};
|
||||
const onCustomEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ProjectSortModeUpdatedDetail>).detail;
|
||||
if (!detail || detail.storageKey !== sortModeStorageKey) return;
|
||||
setSortMode(detail.sortMode);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
|
||||
};
|
||||
}, [sortModeStorageKey]);
|
||||
|
||||
const persistSortMode = useCallback(
|
||||
(value: string) => {
|
||||
const nextSortMode: ProjectSidebarSortMode =
|
||||
value === "alphabetical" || value === "recent" ? value : "top";
|
||||
setSortMode(nextSortMode);
|
||||
if (sortModeStorageKey) {
|
||||
writeProjectSortMode(sortModeStorageKey, nextSortMode);
|
||||
}
|
||||
},
|
||||
[sortModeStorageKey],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
if (!isTopMode) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
@@ -177,38 +273,44 @@ export function SidebarProjects() {
|
||||
|
||||
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||
},
|
||||
[orderedProjects, persistOrder],
|
||||
[isTopMode, orderedProjects, persistOrder],
|
||||
);
|
||||
|
||||
const renderProject = (project: Project) => (
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
activeProjectRef={activeProjectRef}
|
||||
companyId={selectedCompanyId}
|
||||
companyPrefix={selectedCompany?.issuePrefix ?? null}
|
||||
isMobile={isMobile}
|
||||
project={project}
|
||||
projectSidebarSlots={projectSidebarSlots}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="group">
|
||||
<div className="flex items-center px-3 py-1.5">
|
||||
<CollapsibleTrigger className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3 w-3 text-muted-foreground/60 transition-transform opacity-0 group-hover:opacity-100",
|
||||
open && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60">
|
||||
Projects
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openNewProject();
|
||||
}}
|
||||
className="flex items-center justify-center h-4 w-4 rounded text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
aria-label="New project"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarSection
|
||||
label="Projects"
|
||||
collapsible={{ open, onOpenChange: setOpen }}
|
||||
headerAction={{
|
||||
ariaLabel: "New project",
|
||||
icon: Plus,
|
||||
onClick: openNewProject,
|
||||
}}
|
||||
menu={{
|
||||
ariaLabel: "Projects section actions",
|
||||
actions: [
|
||||
{ type: "item", label: "Browse projects", icon: FolderOpen, href: "/projects" },
|
||||
{ type: "separator" },
|
||||
],
|
||||
radioLabel: "Project sort",
|
||||
radioChoices: PROJECT_SORT_CHOICES,
|
||||
radioValue: sortMode,
|
||||
onRadioValueChange: persistSortMode,
|
||||
}}
|
||||
>
|
||||
{isTopMode ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -218,7 +320,7 @@ export function SidebarProjects() {
|
||||
items={orderedProjects.map((project) => project.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{orderedProjects.map((project: Project) => (
|
||||
<SortableProjectItem
|
||||
key={project.id}
|
||||
@@ -234,7 +336,11 @@ export function SidebarProjects() {
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sortedProjects.map((project: Project) => renderProject(project))}
|
||||
</div>
|
||||
)}
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
const sidebarState = vi.hoisted(() => ({
|
||||
isMobile: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => sidebarState,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
if (!globalThis.PointerEvent) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).PointerEvent = MouseEvent;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function openSectionMenu(container: HTMLElement) {
|
||||
const trigger = container.querySelector('button[aria-label="Projects section actions"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
describe("SidebarSection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
|
||||
beforeEach(() => {
|
||||
sidebarState.isMobile = false;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const currentRoot = root;
|
||||
if (currentRoot) {
|
||||
await act(async () => {
|
||||
currentRoot.unmount();
|
||||
});
|
||||
}
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps static and collapsible section labels on the same text column", async () => {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<div>
|
||||
<SidebarSection label="Work">
|
||||
<a href="/issues">Issues</a>
|
||||
</SidebarSection>
|
||||
<SidebarSection label="Projects" collapsible={{ open: true, onOpenChange: vi.fn() }}>
|
||||
<a href="/projects">Projects</a>
|
||||
</SidebarSection>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const workLabel = Array.from(container.querySelectorAll("span"))
|
||||
.find((element) => element.textContent === "Work");
|
||||
const projectsLabel = Array.from(container.querySelectorAll("span"))
|
||||
.find((element) => element.textContent === "Projects");
|
||||
|
||||
expect(workLabel?.parentElement?.textContent).toBe("Work");
|
||||
expect(projectsLabel?.parentElement?.textContent).toBe("Projects");
|
||||
expect(projectsLabel?.parentElement?.querySelector("svg")).toBeNull();
|
||||
expect(container.querySelector('button[aria-label="Collapse Projects"] svg')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps collapse on the caret and opens the menu from the heading", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<SidebarSection
|
||||
label="Projects"
|
||||
collapsible={{ open: true, onOpenChange }}
|
||||
menu={{
|
||||
ariaLabel: "Projects section actions",
|
||||
actions: [{ type: "item", label: "Browse projects", href: "/projects" }],
|
||||
}}
|
||||
>
|
||||
<a href="/projects">Projects</a>
|
||||
</SidebarSection>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await openSectionMenu(container);
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Browse projects");
|
||||
|
||||
const caret = container.querySelector('button[aria-label="Collapse Projects"]');
|
||||
await act(async () => {
|
||||
caret?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("does not apply hover background classes to static section labels", async () => {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<SidebarSection label="Work">
|
||||
<a href="/issues">Issues</a>
|
||||
</SidebarSection>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const workLabel = Array.from(container.querySelectorAll("span"))
|
||||
.find((element) => element.textContent === "Work");
|
||||
const staticLabelControl = workLabel?.parentElement;
|
||||
|
||||
expect(staticLabelControl?.tagName).toBe("DIV");
|
||||
expect(staticLabelControl?.getAttribute("class")).not.toContain("hover:bg-accent/50");
|
||||
expect(staticLabelControl?.getAttribute("class")).not.toContain("focus-visible:bg-accent/50");
|
||||
});
|
||||
|
||||
it("keeps the header action outside the label menu hit area", async () => {
|
||||
const onAction = vi.fn();
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<SidebarSection
|
||||
label="Projects"
|
||||
menu={{
|
||||
ariaLabel: "Projects section actions",
|
||||
actions: [{ type: "item", label: "Browse projects", href: "/projects" }],
|
||||
}}
|
||||
headerAction={{
|
||||
ariaLabel: "New project",
|
||||
icon: Plus,
|
||||
onClick: onAction,
|
||||
}}
|
||||
>
|
||||
<a href="/projects">Projects</a>
|
||||
</SidebarSection>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const sectionMenuTrigger = container.querySelector('button[aria-label="Projects section actions"]');
|
||||
const newProjectButton = container.querySelector('button[aria-label="New project"]');
|
||||
|
||||
expect(sectionMenuTrigger?.textContent).toContain("Projects");
|
||||
expect(sectionMenuTrigger?.querySelector("svg")).toBeNull();
|
||||
expect(sectionMenuTrigger?.getAttribute("class")).toContain("hover:bg-accent/50");
|
||||
expect(newProjectButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
newProjectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.textContent).not.toContain("Browse projects");
|
||||
|
||||
await openSectionMenu(container);
|
||||
expect(document.body.textContent).toContain("Browse projects");
|
||||
});
|
||||
|
||||
it("renders configured menu actions and radio choices", async () => {
|
||||
const onAction = vi.fn();
|
||||
const onRadioValueChange = vi.fn();
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<SidebarSection
|
||||
label="Projects"
|
||||
menu={{
|
||||
ariaLabel: "Projects section actions",
|
||||
actions: [
|
||||
{ type: "item", label: "New project", onSelect: onAction },
|
||||
{ type: "item", label: "Browse projects", href: "/projects" },
|
||||
{ type: "separator" },
|
||||
],
|
||||
radioChoices: [
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "alphabetical", label: "Alphabetical" },
|
||||
],
|
||||
radioValue: "top",
|
||||
onRadioValueChange,
|
||||
}}
|
||||
>
|
||||
<a href="/projects">Projects</a>
|
||||
</SidebarSection>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await openSectionMenu(container);
|
||||
|
||||
const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("New project"));
|
||||
expect(newProjectItem).toBeTruthy();
|
||||
const browseLink = Array.from(document.body.querySelectorAll("a"))
|
||||
.find((element) => element.textContent?.includes("Browse projects"));
|
||||
expect(browseLink?.getAttribute("href")).toBe("/projects");
|
||||
|
||||
const alphabeticalItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]'))
|
||||
.find((element) => element.textContent?.includes("Alphabetical"));
|
||||
expect(alphabeticalItem).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
alphabeticalItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onRadioValueChange).toHaveBeenCalledWith("alphabetical");
|
||||
|
||||
await openSectionMenu(container);
|
||||
const reopenedNewProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("New project"));
|
||||
|
||||
await act(async () => {
|
||||
reopenedNewProjectItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps section header controls visible on mobile", async () => {
|
||||
sidebarState.isMobile = true;
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<SidebarSection
|
||||
label="Projects"
|
||||
collapsible={{ open: false, onOpenChange: vi.fn() }}
|
||||
menu={{
|
||||
ariaLabel: "Projects section actions",
|
||||
actions: [{ type: "item", label: "New project" }],
|
||||
}}
|
||||
headerAction={{
|
||||
ariaLabel: "New project",
|
||||
icon: Plus,
|
||||
onClick: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<a href="/projects">Projects</a>
|
||||
</SidebarSection>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const projectsLabel = Array.from(container.querySelectorAll("span"))
|
||||
.find((element) => element.textContent === "Projects");
|
||||
const caret = container.querySelector('button[aria-label="Expand Projects"] svg');
|
||||
const action = container.querySelector('button[aria-label="New project"]');
|
||||
|
||||
expect(caret?.getAttribute("class")).toContain("opacity-100");
|
||||
expect(caret?.getAttribute("class")).not.toContain("opacity-0");
|
||||
expect(projectsLabel?.parentElement?.textContent).toBe("Projects");
|
||||
expect(action?.getAttribute("class")).toContain("opacity-100");
|
||||
expect(action?.getAttribute("class")).not.toContain("opacity-0");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,212 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ComponentType, type ReactNode } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SidebarSectionIcon = ComponentType<{ className?: string }>;
|
||||
|
||||
export type SidebarSectionMenuAction =
|
||||
| {
|
||||
type: "item";
|
||||
label: string;
|
||||
icon?: SidebarSectionIcon;
|
||||
href?: string;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
| { type: "separator" };
|
||||
|
||||
export type SidebarSectionRadioChoice = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SidebarSectionMenu = {
|
||||
actions?: SidebarSectionMenuAction[];
|
||||
ariaLabel?: string;
|
||||
radioChoices?: SidebarSectionRadioChoice[];
|
||||
radioLabel?: string;
|
||||
radioValue?: string;
|
||||
onRadioValueChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
type SidebarSectionHeaderAction = {
|
||||
ariaLabel: string;
|
||||
icon: SidebarSectionIcon;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
interface SidebarSectionProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
collapsible?: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
menu?: SidebarSectionMenu;
|
||||
headerAction?: SidebarSectionHeaderAction;
|
||||
}
|
||||
|
||||
export function SidebarSection({ label, children }: SidebarSectionProps) {
|
||||
function SidebarSectionHeader({
|
||||
collapsible,
|
||||
headerAction,
|
||||
label,
|
||||
menu,
|
||||
}: Pick<SidebarSectionProps, "collapsible" | "headerAction" | "label" | "menu">) {
|
||||
const { isMobile } = useSidebar();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const hasMenu = Boolean(
|
||||
menu && ((menu.actions?.length ?? 0) > 0 || (menu.radioChoices?.length ?? 0) > 0),
|
||||
);
|
||||
const labelClassName = "text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60";
|
||||
const headerControlVisibilityClassName = isMobile
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
|
||||
const caretClassName = cn(
|
||||
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-all",
|
||||
headerControlVisibilityClassName,
|
||||
collapsible?.open && "rotate-90",
|
||||
menuOpen && "opacity-100",
|
||||
);
|
||||
const actionClassName = cn(
|
||||
"h-5 w-5 shrink-0 text-muted-foreground/60 transition-opacity hover:text-foreground data-[state=open]:opacity-100",
|
||||
headerControlVisibilityClassName,
|
||||
);
|
||||
const headerContent = <span className={labelClassName}>{label}</span>;
|
||||
const HeaderActionIcon = headerAction?.icon;
|
||||
|
||||
const headingControl = hasMenu ? (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex min-w-0 max-w-full items-center rounded-md px-1 py-0.5 text-left outline-none transition-colors",
|
||||
"hover:bg-accent/50 focus-visible:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
menuOpen && "bg-accent/50",
|
||||
)}
|
||||
aria-label={menu?.ariaLabel ?? `${label} actions`}
|
||||
>
|
||||
{headerContent}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
{menu?.actions?.map((action, index) => {
|
||||
if (action.type === "separator") {
|
||||
return <DropdownMenuSeparator key={`separator-${index}`} />;
|
||||
}
|
||||
const Icon = action.icon;
|
||||
const content = (
|
||||
<>
|
||||
{Icon ? <Icon className="size-4" /> : null}
|
||||
<span>{action.label}</span>
|
||||
</>
|
||||
);
|
||||
if (action.href) {
|
||||
return (
|
||||
<DropdownMenuItem key={`${action.label}-${index}`} asChild>
|
||||
<Link to={action.href}>{content}</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem key={`${action.label}-${index}`} onSelect={action.onSelect}>
|
||||
{content}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{menu?.radioChoices && menu.radioChoices.length > 0 ? (
|
||||
<DropdownMenuRadioGroup
|
||||
value={menu.radioValue}
|
||||
onValueChange={menu.onRadioValueChange}
|
||||
aria-label={menu.radioLabel}
|
||||
>
|
||||
{menu.radioChoices.map((choice) => (
|
||||
<DropdownMenuRadioItem key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="inline-flex min-w-0 max-w-full items-center px-1 py-0.5">{headerContent}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60">
|
||||
{label}
|
||||
<div className="group/sidebar-section px-3 py-1.5">
|
||||
<div className="relative flex min-h-6 min-w-0 items-center gap-1">
|
||||
{collapsible ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -left-4 flex h-5 w-5 items-center justify-center rounded-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
aria-label={collapsible.open ? `Collapse ${label}` : `Expand ${label}`}
|
||||
>
|
||||
<ChevronRight className={caretClassName} aria-hidden="true" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
{headingControl}
|
||||
{headerAction && HeaderActionIcon ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={actionClassName}
|
||||
aria-label={headerAction.ariaLabel}
|
||||
onClick={headerAction.onClick}
|
||||
>
|
||||
<HeaderActionIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 mt-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarSection({
|
||||
label,
|
||||
children,
|
||||
collapsible,
|
||||
menu,
|
||||
headerAction,
|
||||
}: SidebarSectionProps) {
|
||||
const content = <div className="flex flex-col gap-0.5 mt-0.5">{children}</div>;
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<Collapsible open={collapsible.open} onOpenChange={collapsible.onOpenChange}>
|
||||
<SidebarSectionHeader
|
||||
label={label}
|
||||
collapsible={collapsible}
|
||||
menu={menu}
|
||||
headerAction={headerAction}
|
||||
/>
|
||||
<CollapsibleContent>{content}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SidebarSectionHeader label={label} menu={menu} headerAction={headerAction} />
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,11 +71,36 @@ describe("StatusIcon", () => {
|
||||
);
|
||||
|
||||
expect(html).not.toContain('data-blocker-attention-state="covered"');
|
||||
expect(html).toContain('aria-label="Blocked · 1 unresolved blocker needs attention"');
|
||||
expect(html).toContain('data-blocker-attention-state="needs_attention"');
|
||||
expect(html).toContain('aria-label="Blocked · 1 blocker needs attention"');
|
||||
expect(html).toContain("border-red-600");
|
||||
expect(html).not.toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("shows active covered work on mixed attention-required blockers", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 5,
|
||||
coveredBlockerCount: 2,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 3,
|
||||
sampleBlockerIdentifier: "PAP-3541",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-blocker-attention-state="needs_attention"');
|
||||
expect(html).toContain('aria-label="Blocked · 3 blockers need attention; 2 covered by active work"');
|
||||
expect(html).toContain("border-red-600");
|
||||
expect(html).not.toContain("border-cyan-600");
|
||||
expect(html).toContain("bg-cyan-600");
|
||||
});
|
||||
|
||||
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
|
||||
@@ -49,8 +49,13 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "attention_required") {
|
||||
const count = blockerAttention.unresolvedBlockerCount;
|
||||
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
||||
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
|
||||
const attentionCopy = `${count} ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
||||
const coveredCount = blockerAttention.coveredBlockerCount;
|
||||
if (coveredCount > 0) {
|
||||
return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`;
|
||||
}
|
||||
return `Blocked · ${attentionCopy}`;
|
||||
}
|
||||
|
||||
return "Blocked";
|
||||
@@ -60,6 +65,8 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||
const [open, setOpen] = useState(false);
|
||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
|
||||
const isAttentionBlocked = status === "blocked" && blockerAttention?.state === "needs_attention";
|
||||
const hasCoveredBlockedWork = isAttentionBlocked && (blockerAttention?.coveredBlockerCount ?? 0) > 0;
|
||||
const colorClass = isCoveredBlocked
|
||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||
: isStalledBlocked
|
||||
@@ -71,7 +78,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||
? "covered"
|
||||
: isStalledBlocked
|
||||
? "stalled"
|
||||
: undefined;
|
||||
: isAttentionBlocked
|
||||
? "needs_attention"
|
||||
: undefined;
|
||||
|
||||
const circle = (
|
||||
<span
|
||||
@@ -91,6 +100,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||
{isCoveredBlocked && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
|
||||
)}
|
||||
{hasCoveredBlockedWork && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-cyan-600 dark:bg-cyan-400" />
|
||||
)}
|
||||
{isStalledBlocked && (
|
||||
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { ReactElement } from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SystemNotice } from "./SystemNotice";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root?.render(element));
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("SystemNotice", () => {
|
||||
it("renders the warning tone label and body in a single status container", () => {
|
||||
const node = render(
|
||||
<SystemNotice
|
||||
tone="warning"
|
||||
body="Paperclip needs a disposition before this issue can continue."
|
||||
/>,
|
||||
);
|
||||
|
||||
const status = node.querySelectorAll('[role="status"]');
|
||||
expect(status.length).toBe(1);
|
||||
expect(status[0]?.getAttribute("aria-label")).toBe("System warning");
|
||||
expect(node.textContent).toContain(
|
||||
"Paperclip needs a disposition before this issue can continue.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses System alert label for danger tone", () => {
|
||||
const node = render(
|
||||
<SystemNotice tone="danger" body="Recovery escalated to CTO." />,
|
||||
);
|
||||
|
||||
const status = node.querySelector('[role="status"]');
|
||||
expect(status?.getAttribute("aria-label")).toBe("System alert");
|
||||
});
|
||||
|
||||
it("uses neutral System notice label by default", () => {
|
||||
const node = render(
|
||||
<SystemNotice tone="neutral" body="Reassigned to ClaudeFixer." />,
|
||||
);
|
||||
|
||||
const status = node.querySelector('[role="status"]');
|
||||
expect(status?.getAttribute("aria-label")).toBe("System notice");
|
||||
});
|
||||
|
||||
it("collapses metadata details by default and toggles aria-expanded on click", () => {
|
||||
const node = render(
|
||||
<SystemNotice
|
||||
tone="warning"
|
||||
body="Needs a disposition."
|
||||
metadata={[
|
||||
{
|
||||
title: "Required action",
|
||||
rows: [
|
||||
{
|
||||
kind: "issue",
|
||||
label: "Source issue",
|
||||
identifier: "PAP-3440",
|
||||
href: "/PAP/issues/PAP-3440",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = node.querySelector("button[aria-expanded]");
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(button?.getAttribute("aria-controls")).not.toBeNull();
|
||||
expect(node.textContent).not.toContain("PAP-3440");
|
||||
|
||||
act(() => {
|
||||
(button as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
const reopened = node.querySelector("button[aria-expanded]");
|
||||
expect(reopened?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(node.textContent).toContain("PAP-3440");
|
||||
});
|
||||
|
||||
it("renders metadata expanded when detailsDefaultOpen is true", () => {
|
||||
const node = render(
|
||||
<SystemNotice
|
||||
tone="warning"
|
||||
body="Needs a disposition."
|
||||
detailsDefaultOpen
|
||||
metadata={[
|
||||
{
|
||||
rows: [{ kind: "text", label: "Suggested action", value: "Pick a disposition" }],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = node.querySelector("button[aria-expanded]");
|
||||
expect(button?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(node.textContent).toContain("Suggested action");
|
||||
expect(node.textContent).toContain("Pick a disposition");
|
||||
});
|
||||
|
||||
it("hides the details affordance when no metadata is provided", () => {
|
||||
const node = render(<SystemNotice tone="warning" body="Short notice." />);
|
||||
|
||||
expect(node.querySelector("button[aria-expanded]")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders typed metadata rows with hrefs when present", () => {
|
||||
const node = render(
|
||||
<SystemNotice
|
||||
tone="danger"
|
||||
body="Recovery blocked"
|
||||
detailsDefaultOpen
|
||||
metadata={[
|
||||
{
|
||||
rows: [
|
||||
{
|
||||
kind: "issue",
|
||||
label: "Recovery issue",
|
||||
identifier: "PAP-3440",
|
||||
href: "/PAP/issues/PAP-3440",
|
||||
title: "Disposition recovery",
|
||||
},
|
||||
{
|
||||
kind: "agent",
|
||||
label: "Owner",
|
||||
name: "CTO",
|
||||
href: "/PAP/agents/cto",
|
||||
},
|
||||
{
|
||||
kind: "run",
|
||||
label: "Source run",
|
||||
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
|
||||
href: "/PAP/agents/codexcoder/runs/9cdba892",
|
||||
status: "succeeded",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const links = Array.from(node.querySelectorAll("a")).map((a) => a.getAttribute("href"));
|
||||
expect(links).toContain("/PAP/issues/PAP-3440");
|
||||
expect(links).toContain("/PAP/agents/cto");
|
||||
expect(links).toContain("/PAP/agents/codexcoder/runs/9cdba892");
|
||||
expect(node.textContent).toContain("PAP-3440");
|
||||
expect(node.textContent).toContain("Disposition recovery");
|
||||
expect(node.textContent).toContain("CTO");
|
||||
expect(node.textContent).toContain("succeeded");
|
||||
});
|
||||
|
||||
it("renders metadata link rows as plain text when href is missing", () => {
|
||||
const node = render(
|
||||
<SystemNotice
|
||||
tone="neutral"
|
||||
body="Reassigned"
|
||||
detailsDefaultOpen
|
||||
metadata={[
|
||||
{
|
||||
rows: [
|
||||
{ kind: "agent", label: "Reassigned to", name: "ClaudeFixer" },
|
||||
{ kind: "run", label: "Run", runId: "abc12345" },
|
||||
{ kind: "issue", label: "Issue", identifier: "PAP-1" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(node.querySelectorAll("a").length).toBe(0);
|
||||
expect(node.textContent).toContain("ClaudeFixer");
|
||||
expect(node.textContent).toContain("abc12345");
|
||||
expect(node.textContent).toContain("PAP-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
Info,
|
||||
OctagonAlert,
|
||||
TriangleAlert,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SystemNoticeTone = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
export type SystemNoticeMetadataRow =
|
||||
| { kind: "text"; label: string; value: string }
|
||||
| { kind: "code"; label: string; value: string }
|
||||
| { kind: "issue"; label: string; identifier: string; href?: string; title?: string }
|
||||
| { kind: "agent"; label: string; name: string; href?: string }
|
||||
| { kind: "run"; label: string; runId: string; href?: string; status?: string };
|
||||
|
||||
export type SystemNoticeMetadataSection = {
|
||||
title?: string;
|
||||
rows: SystemNoticeMetadataRow[];
|
||||
};
|
||||
|
||||
export type SystemNoticeProps = {
|
||||
tone?: SystemNoticeTone;
|
||||
/** Short label that names the system actor + tone, e.g. "System warning". Required so tone is not color-only. */
|
||||
label?: string;
|
||||
/** Short visible body — one or two sentences from the system perspective. */
|
||||
body: ReactNode;
|
||||
/** Optional small chip for the originating run link. */
|
||||
source?: { label: string; href?: string };
|
||||
/** Hidden-by-default metadata. Renders the Details affordance only when present. */
|
||||
metadata?: SystemNoticeMetadataSection[];
|
||||
/** Force the details panel open initially. Defaults to false (collapsed). */
|
||||
detailsDefaultOpen?: boolean;
|
||||
/** Optional ISO timestamp shown next to the label. */
|
||||
timestamp?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type ToneTokens = {
|
||||
container: string;
|
||||
iconWrap: string;
|
||||
icon: LucideIcon;
|
||||
iconClass: string;
|
||||
label: string;
|
||||
divider: string;
|
||||
};
|
||||
|
||||
const TONE_TOKENS: Record<SystemNoticeTone, ToneTokens> = {
|
||||
neutral: {
|
||||
container:
|
||||
"border-border bg-muted/35 dark:bg-muted/20",
|
||||
iconWrap: "bg-muted text-foreground/70",
|
||||
icon: Info,
|
||||
iconClass: "text-muted-foreground",
|
||||
label: "text-muted-foreground",
|
||||
divider: "border-border/70",
|
||||
},
|
||||
info: {
|
||||
container:
|
||||
"border-sky-300/70 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10",
|
||||
iconWrap: "bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200",
|
||||
icon: Info,
|
||||
iconClass: "text-sky-700 dark:text-sky-300",
|
||||
label: "text-sky-800 dark:text-sky-200",
|
||||
divider: "border-sky-300/50 dark:border-sky-500/30",
|
||||
},
|
||||
success: {
|
||||
container:
|
||||
"border-emerald-300/70 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10",
|
||||
iconWrap: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200",
|
||||
icon: CircleCheck,
|
||||
iconClass: "text-emerald-700 dark:text-emerald-300",
|
||||
label: "text-emerald-800 dark:text-emerald-200",
|
||||
divider: "border-emerald-300/50 dark:border-emerald-500/30",
|
||||
},
|
||||
warning: {
|
||||
container:
|
||||
"border-amber-300/70 bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10",
|
||||
iconWrap: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200",
|
||||
icon: TriangleAlert,
|
||||
iconClass: "text-amber-700 dark:text-amber-300",
|
||||
label: "text-amber-900 dark:text-amber-200",
|
||||
divider: "border-amber-300/60 dark:border-amber-500/30",
|
||||
},
|
||||
danger: {
|
||||
container:
|
||||
"border-red-400/60 bg-red-50/80 dark:border-red-500/35 dark:bg-red-500/10",
|
||||
iconWrap: "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-200",
|
||||
icon: OctagonAlert,
|
||||
iconClass: "text-red-700 dark:text-red-300",
|
||||
label: "text-red-900 dark:text-red-200",
|
||||
divider: "border-red-400/50 dark:border-red-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: string) {
|
||||
try {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
function MetadataRow({ row, tone }: { row: SystemNoticeMetadataRow; tone: ToneTokens }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7.5rem_1fr] gap-x-3 gap-y-0.5 px-3 py-1.5 text-xs">
|
||||
<div className="truncate text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{row.label}
|
||||
</div>
|
||||
<div className="min-w-0 break-words text-foreground/90">
|
||||
{(() => {
|
||||
switch (row.kind) {
|
||||
case "text":
|
||||
return <span>{row.value}</span>;
|
||||
case "code":
|
||||
return (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
|
||||
{row.value}
|
||||
</code>
|
||||
);
|
||||
case "issue": {
|
||||
const issueLabel = (
|
||||
<>
|
||||
<span>{row.identifier}</span>
|
||||
{row.title ? (
|
||||
<span className="text-muted-foreground">— {row.title}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
if (row.href) {
|
||||
return (
|
||||
<a
|
||||
href={row.href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline",
|
||||
tone.label,
|
||||
)}
|
||||
>
|
||||
{issueLabel}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1 font-medium", tone.label)}>
|
||||
{issueLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "agent":
|
||||
if (row.href) {
|
||||
return (
|
||||
<a
|
||||
href={row.href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline",
|
||||
tone.label,
|
||||
)}
|
||||
>
|
||||
{row.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={cn("font-medium", tone.label)}>{row.name}</span>
|
||||
);
|
||||
case "run": {
|
||||
const runShort = row.runId.length > 12 ? `${row.runId.slice(0, 8)}…` : row.runId;
|
||||
const inner = (
|
||||
<>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground/80">{runShort}</code>
|
||||
{row.status ? (
|
||||
<span className={cn("font-sans", tone.label)}>{row.status}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
if (row.href) {
|
||||
return (
|
||||
<a
|
||||
href={row.href}
|
||||
className="inline-flex items-center gap-2 rounded-sm font-mono text-[11px] underline-offset-2 hover:underline"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 font-mono text-[11px]">
|
||||
{inner}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemNotice({
|
||||
tone = "neutral",
|
||||
label,
|
||||
body,
|
||||
source,
|
||||
metadata,
|
||||
detailsDefaultOpen = false,
|
||||
timestamp,
|
||||
className,
|
||||
}: SystemNoticeProps) {
|
||||
const tokens = TONE_TOKENS[tone];
|
||||
const ToneIcon = tokens.icon;
|
||||
const [open, setOpen] = useState(detailsDefaultOpen);
|
||||
const detailsId = useId();
|
||||
const hasDetails = Boolean(metadata && metadata.length > 0);
|
||||
const resolvedLabel =
|
||||
label ??
|
||||
{
|
||||
neutral: "System notice",
|
||||
info: "System notice",
|
||||
success: "System notice",
|
||||
warning: "System warning",
|
||||
danger: "System alert",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<section
|
||||
role="status"
|
||||
aria-label={resolvedLabel}
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]",
|
||||
tokens.container,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
|
||||
tokens.iconWrap,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<ToneIcon className={cn("h-4 w-4", tokens.iconClass)} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
|
||||
<span className={tokens.label}>{resolvedLabel}</span>
|
||||
{source ? (
|
||||
<>
|
||||
<span className="text-muted-foreground/60" aria-hidden>·</span>
|
||||
{source.href ? (
|
||||
<a
|
||||
href={source.href}
|
||||
className="rounded-sm font-medium normal-case tracking-normal text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
{source.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium normal-case tracking-normal text-muted-foreground">
|
||||
{source.label}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{timestamp ? (
|
||||
<>
|
||||
<span className="text-muted-foreground/60" aria-hidden>·</span>
|
||||
<span className="font-medium normal-case tracking-normal text-muted-foreground">
|
||||
{formatTimestamp(timestamp)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 break-words text-[14px] leading-6 text-foreground">{body}</div>
|
||||
</div>
|
||||
{hasDetails ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={detailsId}
|
||||
className={cn(
|
||||
"ml-1 inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-transparent px-2 text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground transition-[background-color,border-color,color]",
|
||||
"hover:border-border/70 hover:bg-background/70 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
)}
|
||||
>
|
||||
<span>{open ? "Hide details" : "Details"}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform duration-150",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
{hasDetails && open ? (
|
||||
<div
|
||||
id={detailsId}
|
||||
className={cn(
|
||||
"border-t bg-background/50 dark:bg-background/30",
|
||||
tokens.divider,
|
||||
)}
|
||||
>
|
||||
<div className="divide-y divide-border/50 px-1 py-1">
|
||||
{metadata!.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
|
||||
{section.title ? (
|
||||
<div className="px-3 pb-1 pt-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{section.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{section.rows.map((row, rowIdx) => (
|
||||
<MetadataRow key={rowIdx} row={row} tone={tokens} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemNotice;
|
||||
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildWorkspaceRuntimeControlItems,
|
||||
buildWorkspaceRuntimeControlSections,
|
||||
WorkspaceRuntimeQuickControls,
|
||||
WorkspaceRuntimeControls,
|
||||
} from "./WorkspaceRuntimeControls";
|
||||
|
||||
@@ -293,6 +294,41 @@ describe("WorkspaceRuntimeControls", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("lets quick action buttons inherit the shared button shape tokens", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
||||
],
|
||||
canStartServices: true,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceRuntimeQuickControls
|
||||
sections={sections}
|
||||
onAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
expect(buttons).toHaveLength(2);
|
||||
for (const button of buttons) {
|
||||
expect(button.className).toContain("rounded-md");
|
||||
expect(button.className).not.toContain("rounded-none");
|
||||
expect(button.className).not.toContain("rounded-xl");
|
||||
expect(button.className).not.toContain("shadow-none");
|
||||
}
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows disabled actions when local command prerequisites are missing", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
|
||||
@@ -192,6 +192,15 @@ export function buildWorkspaceRuntimeControlItems(input: {
|
||||
}));
|
||||
}
|
||||
|
||||
export function getRunningRuntimeServiceUrl(
|
||||
sections: WorkspaceRuntimeControlSections,
|
||||
) {
|
||||
const runningService = [...sections.services, ...sections.otherServices].find(
|
||||
(item) => (item.statusLabel === "running" || item.statusLabel === "starting") && item.url,
|
||||
);
|
||||
return runningService?.url ?? null;
|
||||
}
|
||||
|
||||
function requestMatchesPending(
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined,
|
||||
nextRequest: WorkspaceRuntimeControlRequest,
|
||||
@@ -255,9 +264,8 @@ function CommandActionButtons({
|
||||
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 w-full justify-start px-3 shadow-none sm:w-auto",
|
||||
square ? "rounded-none" : "rounded-xl",
|
||||
action === "restart" ? "bg-background" : null,
|
||||
"w-full justify-start sm:w-auto",
|
||||
square ? "rounded-none" : null,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => onAction(request)}
|
||||
@@ -451,3 +459,56 @@ export function WorkspaceRuntimeControls({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceRuntimeQuickControls({
|
||||
sections,
|
||||
isPending = false,
|
||||
pendingRequest = null,
|
||||
onAction,
|
||||
square,
|
||||
}: {
|
||||
sections: WorkspaceRuntimeControlSections;
|
||||
isPending?: boolean;
|
||||
pendingRequest?: WorkspaceRuntimeControlRequest | null;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
square?: boolean;
|
||||
}) {
|
||||
const controlItems = sections.services.length > 0 ? sections.services : sections.otherServices;
|
||||
const serviceUrl = getRunningRuntimeServiceUrl(sections);
|
||||
|
||||
if (controlItems.length === 0 && !serviceUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
{controlItems.length > 0 ? (
|
||||
<div className="flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{controlItems.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 flex-col gap-1 sm:items-end">
|
||||
{controlItems.length > 1 ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.title}</span>
|
||||
) : null}
|
||||
<CommandActionButtons
|
||||
item={item}
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
square={square}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{serviceUrl ? (
|
||||
<a
|
||||
href={serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline sm:self-end"
|
||||
>
|
||||
{serviceUrl}
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ export const help: Record<string, string> = {
|
||||
wakeOnDemand: "Allow this agent to be woken by assignments, API calls, UI actions, or automated systems.",
|
||||
cooldownSec: "Minimum seconds between consecutive heartbeat runs.",
|
||||
maxConcurrentRuns: "Maximum number of heartbeat runs that can execute simultaneously for this agent.",
|
||||
maxTurnContinuationEnabled: "Automatically queue bounded continuation runs when an adapter stops because its per-run turn cap was exhausted.",
|
||||
maxTurnContinuationMaxAttempts: "Maximum automatic continuations after one max-turn stop. This is separate from max turns per run.",
|
||||
maxTurnContinuationDelaySec: "Seconds to wait before starting each max-turn continuation.",
|
||||
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { CompanySearchHighlight } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface HighlightedTextProps {
|
||||
text: string;
|
||||
highlights?: readonly CompanySearchHighlight[] | null;
|
||||
className?: string;
|
||||
markClassName?: string;
|
||||
}
|
||||
|
||||
function clampedRanges(text: string, highlights: readonly CompanySearchHighlight[]) {
|
||||
const result: Array<{ start: number; end: number }> = [];
|
||||
for (const range of highlights) {
|
||||
const start = Math.max(0, Math.min(text.length, range.start));
|
||||
const end = Math.max(start, Math.min(text.length, range.end));
|
||||
if (end <= start) continue;
|
||||
result.push({ start, end });
|
||||
}
|
||||
result.sort((a, b) => a.start - b.start);
|
||||
const merged: Array<{ start: number; end: number }> = [];
|
||||
for (const range of result) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && range.start <= last.end) {
|
||||
last.end = Math.max(last.end, range.end);
|
||||
} else {
|
||||
merged.push({ ...range });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function HighlightedText({ text, highlights, className, markClassName }: HighlightedTextProps) {
|
||||
const ranges = highlights && highlights.length > 0 ? clampedRanges(text, highlights) : [];
|
||||
if (ranges.length === 0) {
|
||||
return <span className={className}>{text}</span>;
|
||||
}
|
||||
const segments: Array<{ key: string; text: string; highlight: boolean }> = [];
|
||||
let cursor = 0;
|
||||
ranges.forEach((range, index) => {
|
||||
if (range.start > cursor) {
|
||||
segments.push({ key: `t-${index}`, text: text.slice(cursor, range.start), highlight: false });
|
||||
}
|
||||
segments.push({ key: `m-${index}`, text: text.slice(range.start, range.end), highlight: true });
|
||||
cursor = range.end;
|
||||
});
|
||||
if (cursor < text.length) {
|
||||
segments.push({ key: "t-end", text: text.slice(cursor), highlight: false });
|
||||
}
|
||||
return (
|
||||
<span className={className}>
|
||||
{segments.map((segment) =>
|
||||
segment.highlight ? (
|
||||
<mark
|
||||
key={segment.key}
|
||||
className={cn(
|
||||
"rounded-sm bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-300/30",
|
||||
markClassName,
|
||||
)}
|
||||
>
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={segment.key}>{segment.text}</span>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type MatchSourceChipKind = "title" | "identifier" | "comment" | "document";
|
||||
|
||||
const chipStyles: Record<MatchSourceChipKind, string> = {
|
||||
title:
|
||||
"bg-[var(--chip-match-title-bg)] text-[var(--chip-match-title-fg)] border-[var(--chip-match-title-border)]",
|
||||
identifier:
|
||||
"bg-[var(--chip-match-identifier-bg)] text-[var(--chip-match-identifier-fg)] border-[var(--chip-match-identifier-border)]",
|
||||
comment:
|
||||
"bg-[var(--chip-match-comment-bg)] text-[var(--chip-match-comment-fg)] border-[var(--chip-match-comment-border)]",
|
||||
document:
|
||||
"bg-[var(--chip-match-document-bg)] text-[var(--chip-match-document-fg)] border-[var(--chip-match-document-border)]",
|
||||
};
|
||||
|
||||
const chipLabels: Record<MatchSourceChipKind, string> = {
|
||||
title: "Title",
|
||||
identifier: "Identifier",
|
||||
comment: "Comment",
|
||||
document: "Doc",
|
||||
};
|
||||
|
||||
export interface MatchSourceChipProps {
|
||||
kind: MatchSourceChipKind;
|
||||
count?: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MatchSourceChip({ kind, count, label, className }: MatchSourceChipProps) {
|
||||
const text = label ?? chipLabels[kind];
|
||||
const showCount = typeof count === "number" && count > 1;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-px text-[11px] font-medium leading-none whitespace-nowrap",
|
||||
chipStyles[kind],
|
||||
className,
|
||||
)}
|
||||
data-kind={kind}
|
||||
>
|
||||
{text}
|
||||
{showCount ? <span className="opacity-80">×{count}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { memo, type ComponentType, type SVGProps } from "react";
|
||||
import { Bot, FileText, Hexagon, MessageSquare, Quote } from "lucide-react";
|
||||
import type { Agent, CompanySearchResult } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StatusIcon } from "../StatusIcon";
|
||||
import { Identity } from "../Identity";
|
||||
import { HighlightedText, type HighlightedTextProps } from "./HighlightedText";
|
||||
|
||||
type SnippetStyle = {
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const SNIPPET_STYLES: Record<string, SnippetStyle> = {
|
||||
comment: { Icon: MessageSquare, label: "Comment" },
|
||||
document: { Icon: FileText, label: "Doc" },
|
||||
description: { Icon: Quote, label: "Description" },
|
||||
};
|
||||
|
||||
function snippetStyle(field: string, fallbackLabel: string): SnippetStyle {
|
||||
return SNIPPET_STYLES[field] ?? { Icon: Quote, label: fallbackLabel };
|
||||
}
|
||||
|
||||
function formatRelativeTime(input: string | null): string {
|
||||
if (!input) return "";
|
||||
const value = new Date(input);
|
||||
if (Number.isNaN(value.getTime())) return "";
|
||||
const diffMs = Date.now() - value.getTime();
|
||||
const seconds = Math.round(diffMs / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 7) return `${days}d`;
|
||||
const weeks = Math.round(days / 7);
|
||||
if (weeks < 5) return `${weeks}w`;
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return `${months}mo`;
|
||||
const years = Math.round(days / 365);
|
||||
return `${years}y`;
|
||||
}
|
||||
|
||||
export interface SearchResultRowProps {
|
||||
result: CompanySearchResult;
|
||||
agentsById?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
isActive?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ROW_BASE =
|
||||
"group flex items-start gap-3 rounded-md px-3 transition-colors no-underline text-inherit hover:bg-muted/40";
|
||||
|
||||
function SearchResultRowImpl({
|
||||
result,
|
||||
agentsById,
|
||||
isActive,
|
||||
className,
|
||||
}: SearchResultRowProps) {
|
||||
if (result.type === "agent") {
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(ROW_BASE, "py-3", isActive && "bg-muted/40", className)}
|
||||
data-result-type="agent"
|
||||
>
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Bot className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{result.title}</span>
|
||||
</div>
|
||||
{result.snippet ? (
|
||||
<SnippetLine
|
||||
text={result.snippets[0]?.text ?? result.snippet}
|
||||
highlights={result.snippets[0]?.highlights}
|
||||
field="agent"
|
||||
fallbackLabel={result.sourceLabel ?? "Agent"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.type === "project") {
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(ROW_BASE, "py-3", isActive && "bg-muted/40", className)}
|
||||
data-result-type="project"
|
||||
>
|
||||
<Hexagon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate text-sm font-medium">{result.title}</span>
|
||||
{result.snippet ? (
|
||||
<SnippetLine
|
||||
text={result.snippets[0]?.text ?? result.snippet}
|
||||
highlights={result.snippets[0]?.highlights}
|
||||
field="project"
|
||||
fallbackLabel={result.sourceLabel ?? "Project"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const issue = result.issue;
|
||||
if (!issue) return null;
|
||||
const assigneeName = issue.assigneeAgentId
|
||||
? agentsById?.get(issue.assigneeAgentId)?.name ?? null
|
||||
: null;
|
||||
const updated = formatRelativeTime(result.updatedAt ?? issue.updatedAt);
|
||||
const titleHighlights = result.snippets.find((snippet) => snippet.field === "title")?.highlights;
|
||||
const bodySnippets = result.snippets.filter((snippet) => snippet.field !== "title").slice(0, 2);
|
||||
const previewImageUrl = result.previewImageUrl;
|
||||
const hasRightRail = previewImageUrl || assigneeName || updated;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
disableIssueQuicklook
|
||||
className={cn(ROW_BASE, "py-4", isActive && "bg-muted/40", className)}
|
||||
data-result-type="issue"
|
||||
>
|
||||
<div className="mt-1 shrink-0">
|
||||
<StatusIcon status={issue.status} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||
{issue.identifier ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
) : null}
|
||||
<HighlightedText
|
||||
text={issue.title}
|
||||
highlights={titleHighlights}
|
||||
className="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground"
|
||||
/>
|
||||
</div>
|
||||
{bodySnippets.map((snippet, index) => (
|
||||
<SnippetLine
|
||||
key={`${snippet.field}-${index}`}
|
||||
text={snippet.text}
|
||||
highlights={snippet.highlights}
|
||||
field={snippet.field}
|
||||
fallbackLabel={snippet.label}
|
||||
multiline
|
||||
/>
|
||||
))}
|
||||
{hasRightRail ? (
|
||||
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground sm:hidden">
|
||||
{assigneeName ? <span className="truncate">{assigneeName}</span> : null}
|
||||
{updated ? <span className="ml-auto tabular-nums">{updated}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{hasRightRail ? (
|
||||
<div className="ml-2 hidden shrink-0 flex-col items-end gap-2 sm:flex">
|
||||
{assigneeName || updated ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{assigneeName ? <Identity name={assigneeName} size="sm" /> : null}
|
||||
{updated ? <span className="tabular-nums">{updated}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{previewImageUrl ? (
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-[88px] w-[88px] shrink-0 rounded-md border border-border bg-muted object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const SearchResultRow = memo(SearchResultRowImpl);
|
||||
|
||||
interface SnippetLineProps {
|
||||
text: string;
|
||||
highlights?: HighlightedTextProps["highlights"];
|
||||
field: string;
|
||||
fallbackLabel: string;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
function SnippetLine({ text, highlights, field, fallbackLabel, multiline = false }: SnippetLineProps) {
|
||||
const { Icon, label } = snippetStyle(field, fallbackLabel);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2.5 flex min-w-0 gap-1.5 text-xs text-muted-foreground",
|
||||
multiline ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground/60", multiline && "mt-0.5")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">{label}: </span>
|
||||
<HighlightedText
|
||||
text={text}
|
||||
highlights={highlights}
|
||||
className={multiline ? "line-clamp-2 leading-relaxed" : "line-clamp-1 truncate"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BreadcrumbProvider, useBreadcrumbs } from "./BreadcrumbContext";
|
||||
import { BreadcrumbProvider, buildDocumentTitle, useBreadcrumbs } from "./BreadcrumbContext";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -58,4 +58,21 @@ describe("BreadcrumbContext", () => {
|
||||
|
||||
expect(renderCounts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("builds page titles with the selected company name before Paperclip", () => {
|
||||
expect(buildDocumentTitle([{ label: "Inbox" }], "Anachronist Wiki")).toBe(
|
||||
"Inbox • Anachronist Wiki • Paperclip",
|
||||
);
|
||||
expect(
|
||||
buildDocumentTitle(
|
||||
[{ label: "Issues", href: "/issues" }, { label: "PAP-3515" }],
|
||||
"Anachronist Wiki",
|
||||
),
|
||||
).toBe("PAP-3515 • Issues • Anachronist Wiki • Paperclip");
|
||||
});
|
||||
|
||||
it("omits blank company names from page titles", () => {
|
||||
expect(buildDocumentTitle([{ label: "Inbox" }], " ")).toBe("Inbox • Paperclip");
|
||||
expect(buildDocumentTitle([], null)).toBe("Paperclip");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,11 @@ interface BreadcrumbContextValue {
|
||||
setMobileToolbar: (node: ReactNode | null) => void;
|
||||
}
|
||||
|
||||
interface BreadcrumbProviderProps {
|
||||
children: ReactNode;
|
||||
companyName?: string | null;
|
||||
}
|
||||
|
||||
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
|
||||
|
||||
function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
|
||||
@@ -25,7 +30,16 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||
export function buildDocumentTitle(breadcrumbs: Breadcrumb[], companyName?: string | null) {
|
||||
const pageParts = breadcrumbs.length === 0
|
||||
? []
|
||||
: [...breadcrumbs].reverse().map((breadcrumb) => breadcrumb.label);
|
||||
const companyPart = companyName?.trim() ? [companyName.trim()] : [];
|
||||
const parts = [...pageParts, ...companyPart, "Paperclip"];
|
||||
return parts.join(" • ");
|
||||
}
|
||||
|
||||
export function BreadcrumbProvider({ children, companyName }: BreadcrumbProviderProps) {
|
||||
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
|
||||
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
|
||||
|
||||
@@ -38,13 +52,8 @@ export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (breadcrumbs.length === 0) {
|
||||
document.title = "Paperclip";
|
||||
} else {
|
||||
const parts = [...breadcrumbs].reverse().map((b) => b.label);
|
||||
document.title = `${parts.join(" · ")} · Paperclip`;
|
||||
}
|
||||
}, [breadcrumbs]);
|
||||
document.title = buildDocumentTitle(breadcrumbs, companyName);
|
||||
}, [breadcrumbs, companyName]);
|
||||
|
||||
return (
|
||||
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs, mobileToolbar, setMobileToolbar }}>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import type { IssueWorkMode } from "@paperclipai/shared";
|
||||
|
||||
interface NewIssueDefaults {
|
||||
status?: string;
|
||||
workMode?: IssueWorkMode;
|
||||
priority?: string;
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
|
||||
@@ -330,11 +330,24 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
executionAgentNameKey: "codexcoder",
|
||||
executionLockedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||
}],
|
||||
[JSON.stringify(queryKeys.issues.detail("issue-1")), {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
executionRunId: "run-1",
|
||||
executionAgentNameKey: "codexcoder",
|
||||
executionLockedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||
}],
|
||||
[JSON.stringify(queryKeys.issues.activeRun("PAP-759")), {
|
||||
id: "run-1",
|
||||
}],
|
||||
[JSON.stringify(queryKeys.issues.activeRun("issue-1")), {
|
||||
id: "run-1",
|
||||
}],
|
||||
[JSON.stringify(queryKeys.issues.liveRuns("PAP-759")), [{ id: "run-1" }]],
|
||||
[JSON.stringify(queryKeys.issues.liveRuns("issue-1")), [{ id: "run-1" }]],
|
||||
[JSON.stringify(queryKeys.issues.runs("PAP-759")), [{ runId: "run-1" }]],
|
||||
[JSON.stringify(queryKeys.issues.runs("issue-1")), [{ runId: "run-1" }]],
|
||||
]);
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
@@ -377,6 +390,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.activeRun("PAP-759"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.activeRun("issue-1"),
|
||||
});
|
||||
expect(cache.get(JSON.stringify(queryKeys.issues.activeRun("PAP-759")))).toBeNull();
|
||||
expect(cache.get(JSON.stringify(queryKeys.issues.liveRuns("PAP-759")))).toEqual([]);
|
||||
expect(cache.get(JSON.stringify(queryKeys.issues.detail("PAP-759")))).toMatchObject({
|
||||
@@ -384,6 +400,13 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
});
|
||||
expect(cache.get(JSON.stringify(queryKeys.issues.activeRun("issue-1")))).toBeNull();
|
||||
expect(cache.get(JSON.stringify(queryKeys.issues.liveRuns("issue-1")))).toEqual([]);
|
||||
expect(cache.get(JSON.stringify(queryKeys.issues.detail("issue-1")))).toMatchObject({
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores run status events for other issues", () => {
|
||||
|
||||
@@ -279,25 +279,29 @@ function invalidateVisibleIssueRunQueries(
|
||||
|
||||
const status = readString(payload.status);
|
||||
if (runId && status && TERMINAL_RUN_STATUSES.has(status)) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.liveRuns(context.routeIssueRef),
|
||||
(current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId),
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.activeRun(context.routeIssueRef),
|
||||
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(context.routeIssueRef),
|
||||
(current: Issue | undefined) => clearIssueExecutionRun(current, runId),
|
||||
);
|
||||
for (const issueRef of context.issueRefs) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.liveRuns(issueRef),
|
||||
(current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId),
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.activeRun(issueRef),
|
||||
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(issueRef),
|
||||
(current: Issue | undefined) => clearIssueExecutionRun(current, runId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(context.routeIssueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(context.routeIssueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(context.routeIssueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(context.routeIssueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(context.routeIssueRef) });
|
||||
for (const issueRef of context.issueRefs) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueRef) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ function createComment(index: number): IssueChatComment {
|
||||
id: `long-thread-comment-${String(index + 1).padStart(3, "0")}`,
|
||||
companyId: "company-long-thread",
|
||||
issueId: "issue-long-thread",
|
||||
authorType: authorAgentId ? "agent" : "user",
|
||||
authorAgentId,
|
||||
authorUserId: authorAgentId ? null : "user-board",
|
||||
body: isMarkdown
|
||||
@@ -101,6 +102,8 @@ function createComment(index: number): IssueChatComment {
|
||||
: authorAgentId
|
||||
? plainAssistantBody(index + 1)
|
||||
: plainUserBody(index + 1),
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: atMinute(index),
|
||||
updatedAt: atMinute(index),
|
||||
};
|
||||
|
||||
@@ -43,17 +43,21 @@ function createAgent(
|
||||
}
|
||||
|
||||
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
|
||||
return {
|
||||
const merged: IssueChatComment = {
|
||||
id: "comment-default",
|
||||
companyId: "company-ux",
|
||||
issueId: "issue-ux",
|
||||
authorType: overrides.authorAgentId ? "agent" : "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
const primaryAgent = createAgent("agent-1", "CodexCoder", "code", "codexcoder");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user