mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Merge public-gh/master into PAP-881-document-revisions-bulid-it
This commit is contained in:
commit
41f261eaf5
194 changed files with 29520 additions and 2185 deletions
|
|
@ -11,6 +11,7 @@ import { Agents } from "./pages/Agents";
|
|||
import { AgentDetail } from "./pages/AgentDetail";
|
||||
import { Projects } from "./pages/Projects";
|
||||
import { ProjectDetail } from "./pages/ProjectDetail";
|
||||
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
|
||||
import { Issues } from "./pages/Issues";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { Routines } from "./pages/Routines";
|
||||
|
|
@ -144,6 +145,8 @@ function boardRoutes() {
|
|||
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<ProjectWorkspaceDetail />} />
|
||||
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
|
||||
<Route path="issues" element={<Issues />} />
|
||||
|
|
@ -165,10 +168,11 @@ function boardRoutes() {
|
|||
<Route path="costs" element={<Costs />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
<Route path="inbox" element={<InboxRootRedirect />} />
|
||||
<Route path="inbox/mine" element={<Inbox />} />
|
||||
<Route path="inbox/recent" element={<Inbox />} />
|
||||
<Route path="inbox/unread" element={<Inbox />} />
|
||||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
|
|
@ -336,7 +340,10 @@ export function App() {
|
|||
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path=":companyPrefix" element={<Layout />}>
|
||||
{boardRoutes()}
|
||||
|
|
|
|||
49
ui/src/adapters/hermes-local/config-fields.tsx
Normal file
49
ui/src/adapters/hermes-local/config-fields.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
|
||||
export function HermesLocalConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
if (hideInstructionsFile) return null;
|
||||
return (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/hermes-local/index.ts
Normal file
12
ui/src/adapters/hermes-local/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { UIAdapterModule } from "../types";
|
||||
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
|
||||
import { HermesLocalConfigFields } from "./config-fields";
|
||||
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
|
||||
|
||||
export const hermesLocalUIAdapter: UIAdapterModule = {
|
||||
type: "hermes_local",
|
||||
label: "Hermes Agent",
|
||||
parseStdoutLine: parseHermesStdoutLine,
|
||||
ConfigFields: HermesLocalConfigFields,
|
||||
buildAdapterConfig: buildHermesConfig,
|
||||
};
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
ToggleField,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
|
|
@ -19,31 +21,52 @@ export function OpenCodeLocalConfigFields({
|
|||
mark,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
if (hideInstructionsFile) return null;
|
||||
return (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
<>
|
||||
{!hideInstructionsFile && (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<ToggleField
|
||||
label="Skip permissions"
|
||||
hint={help.dangerouslySkipPermissions}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslySkipPermissions
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslySkipPermissions",
|
||||
config.dangerouslySkipPermissions !== false,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslySkipPermissions: v })
|
||||
: mark("adapterConfig", "dangerouslySkipPermissions", v)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local";
|
|||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||
import { hermesLocalUIAdapter } from "./hermes-local";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { piLocalUIAdapter } from "./pi-local";
|
||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||
|
|
@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [
|
|||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
hermesLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { api } from "./client";
|
|||
type InviteSummary = {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
companyName?: string | null;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
expiresAt: string;
|
||||
|
|
@ -87,6 +88,7 @@ type CompanyInviteCreated = {
|
|||
inviteUrl: string;
|
||||
expiresAt: string;
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
companyName?: string | null;
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ export interface AdapterModel {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface DetectedAdapterModel {
|
||||
model: string;
|
||||
provider: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface ClaudeLoginResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
|
|
@ -159,6 +165,10 @@ export const agentsApi = {
|
|||
api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||
),
|
||||
detectModel: (companyId: string, type: string) =>
|
||||
api.get<DetectedAdapterModel | null>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
||||
),
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const executionWorkspacesApi = {
|
||||
|
|
@ -22,5 +22,14 @@ export const executionWorkspacesApi = {
|
|||
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
|
||||
getCloseReadiness: (id: string) =>
|
||||
api.get<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
|
||||
listWorkspaceOperations: (id: string) =>
|
||||
api.get<WorkspaceOperation[]>(`/execution-workspaces/${id}/workspace-operations`),
|
||||
controlRuntimeServices: (id: string, action: "start" | "stop" | "restart") =>
|
||||
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
||||
`/execution-workspaces/${id}/runtime-services/${action}`,
|
||||
{},
|
||||
),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import type {
|
|||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export type IssueUpdateResponse = Issue & {
|
||||
comment?: IssueComment | null;
|
||||
};
|
||||
|
||||
export const issuesApi = {
|
||||
list: (
|
||||
companyId: string,
|
||||
|
|
@ -21,8 +25,10 @@ export const issuesApi = {
|
|||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
inboxArchivedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
labelId?: string;
|
||||
executionWorkspaceId?: string;
|
||||
originKind?: string;
|
||||
originId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
|
|
@ -36,8 +42,10 @@ export const issuesApi = {
|
|||
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
||||
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
||||
if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId);
|
||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||
if (filters?.labelId) params.set("labelId", filters.labelId);
|
||||
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
|
||||
if (filters?.originKind) params.set("originKind", filters.originKind);
|
||||
if (filters?.originId) params.set("originId", filters.originId);
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
|
|
@ -51,9 +59,15 @@ export const issuesApi = {
|
|||
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
|
||||
get: (id: string) => api.get<Issue>(`/issues/${id}`),
|
||||
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
|
||||
markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`),
|
||||
archiveFromInbox: (id: string) =>
|
||||
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
|
||||
unarchiveFromInbox: (id: string) =>
|
||||
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
|
||||
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
||||
checkout: (id: string, agentId: string) =>
|
||||
api.post<Issue>(`/issues/${id}/checkout`, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
function withCompanyScope(path: string, companyId?: string) {
|
||||
|
|
@ -27,6 +27,16 @@ export const projectsApi = {
|
|||
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`),
|
||||
data,
|
||||
),
|
||||
controlWorkspaceRuntimeServices: (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
action: "start" | "stop" | "restart",
|
||||
companyId?: string,
|
||||
) =>
|
||||
api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>(
|
||||
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`),
|
||||
{},
|
||||
),
|
||||
removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) =>
|
||||
api.delete<ProjectWorkspace>(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)),
|
||||
remove: (id: string, companyId?: string) => api.delete<Project>(projectPath(id, companyId)),
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ const codexThinkingEffortOptions = [
|
|||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "xhigh", label: "X-High" },
|
||||
] as const;
|
||||
|
||||
const openCodeThinkingEffortOptions = [
|
||||
|
|
@ -148,6 +149,7 @@ const openCodeThinkingEffortOptions = [
|
|||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "xhigh", label: "X-High" },
|
||||
{ id: "max", label: "Max" },
|
||||
] as const;
|
||||
|
||||
|
|
@ -248,9 +250,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
if (overlay.adapterType !== undefined) {
|
||||
patch.adapterType = overlay.adapterType;
|
||||
// When adapter type changes, send only the new config — don't merge
|
||||
// with old config since old adapter fields are meaningless for the new type
|
||||
patch.adapterConfig = overlay.adapterConfig;
|
||||
// When adapter type changes, replace adapter-specific fields but preserve
|
||||
// adapter-agnostic fields (env, promptTemplate, etc.) that are shared
|
||||
// across all adapter types.
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
const adapterAgnosticKeys = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
];
|
||||
const preserved: Record<string, unknown> = {};
|
||||
for (const key of adapterAgnosticKeys) {
|
||||
if (key in existing) {
|
||||
preserved[key] = existing[key];
|
||||
}
|
||||
}
|
||||
patch.adapterConfig = { ...preserved, ...overlay.adapterConfig };
|
||||
} else if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
||||
|
|
@ -296,9 +315,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
const isHermesLocal = adapterType === "hermes_local";
|
||||
const showLegacyWorkingDirectoryField =
|
||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
|
|
@ -315,6 +336,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const models = fetchedModels ?? externalModels ?? [];
|
||||
const {
|
||||
data: detectedModelData,
|
||||
refetch: refetchDetectedModel,
|
||||
} = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.agents.detectModel(selectedCompanyId, adapterType)
|
||||
: ["agents", "none", "detect-model", adapterType],
|
||||
queryFn: () => {
|
||||
if (!selectedCompanyId) {
|
||||
throw new Error("Select a company to detect the Hermes model");
|
||||
}
|
||||
return agentsApi.detectModel(selectedCompanyId, adapterType);
|
||||
},
|
||||
enabled: Boolean(selectedCompanyId && isHermesLocal),
|
||||
});
|
||||
const detectedModel = detectedModelData?.model ?? null;
|
||||
|
||||
const { data: companyAgents = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
||||
|
|
@ -688,6 +725,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
|
|
@ -709,9 +748,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
open={modelOpen}
|
||||
onOpenChange={setModelOpen}
|
||||
allowDefault={adapterType !== "opencode_local"}
|
||||
required={adapterType === "opencode_local"}
|
||||
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
|
||||
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
|
||||
groupByProvider={adapterType === "opencode_local"}
|
||||
creatable={adapterType === "hermes_local"}
|
||||
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
|
||||
onDetectModel={adapterType === "hermes_local"
|
||||
? async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}
|
||||
: undefined}
|
||||
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
|
||||
/>
|
||||
{fetchedModelsError && (
|
||||
<p className="text-xs text-destructive">
|
||||
|
|
@ -976,7 +1024,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||
|
|
@ -1293,6 +1341,10 @@ function ModelDropdown({
|
|||
allowDefault,
|
||||
required,
|
||||
groupByProvider,
|
||||
creatable,
|
||||
detectedModel,
|
||||
onDetectModel,
|
||||
detectModelLabel,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
|
|
@ -1302,9 +1354,20 @@ function ModelDropdown({
|
|||
allowDefault: boolean;
|
||||
required: boolean;
|
||||
groupByProvider: boolean;
|
||||
creatable?: boolean;
|
||||
detectedModel?: string | null;
|
||||
onDetectModel?: () => Promise<string | null>;
|
||||
detectModelLabel?: string;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const [detectingModel, setDetectingModel] = useState(false);
|
||||
const selected = models.find((m) => m.id === value);
|
||||
const manualModel = modelSearch.trim();
|
||||
const canCreateManualModel = Boolean(
|
||||
creatable &&
|
||||
manualModel &&
|
||||
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
|
||||
);
|
||||
const filteredModels = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (!modelSearch.trim()) return true;
|
||||
|
|
@ -1341,6 +1404,21 @@ function ModelDropdown({
|
|||
}));
|
||||
}, [filteredModels, groupByProvider]);
|
||||
|
||||
async function handleDetectModel() {
|
||||
if (!onDetectModel) return;
|
||||
setDetectingModel(true);
|
||||
try {
|
||||
const nextModel = await onDetectModel();
|
||||
if (nextModel) {
|
||||
onChange(nextModel);
|
||||
onOpenChange(false);
|
||||
setModelSearch("");
|
||||
}
|
||||
} finally {
|
||||
setDetectingModel(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Field label="Model" hint={help.model}>
|
||||
<Popover
|
||||
|
|
@ -1351,7 +1429,7 @@ function ModelDropdown({
|
|||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className={cn(!value && "text-muted-foreground")}>
|
||||
{selected
|
||||
? selected.label
|
||||
|
|
@ -1361,16 +1439,84 @@ function ModelDropdown({
|
|||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative mb-1">
|
||||
<input
|
||||
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
|
||||
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{modelSearch && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setModelSearch("")}
|
||||
>
|
||||
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDetectModel && !detectedModel && !modelSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||
onClick={() => {
|
||||
void handleDetectModel();
|
||||
}}
|
||||
disabled={detectingModel}
|
||||
>
|
||||
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
|
||||
</button>
|
||||
)}
|
||||
{value && !models.some((m) => m.id === value) && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
|
||||
current
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{detectedModel && detectedModel !== value && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(detectedModel);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
|
||||
{detectedModel}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
|
||||
detected
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{allowDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!value && "bg-accent",
|
||||
|
|
@ -1383,6 +1529,20 @@ function ModelDropdown({
|
|||
Default
|
||||
</button>
|
||||
)}
|
||||
{canCreateManualModel && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
onChange(manualModel);
|
||||
onOpenChange(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<span>Use manual model</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
|
||||
</button>
|
||||
)}
|
||||
{groupedModels.map((group) => (
|
||||
<div key={group.provider} className="mb-1 last:mb-0">
|
||||
{groupByProvider && (
|
||||
|
|
@ -1392,6 +1552,7 @@ function ModelDropdown({
|
|||
)}
|
||||
{group.entries.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
|
|
@ -1409,8 +1570,14 @@ function ModelDropdown({
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{onDetectModel
|
||||
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
|
||||
: "No models found."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,16 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
|
|||
import { StatusBadge } from "./StatusBadge";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { formatDateTime } from "../lib/utils";
|
||||
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
|
||||
interface CommentWithRunMeta extends IssueComment {
|
||||
runId?: string | null;
|
||||
runAgentId?: string | null;
|
||||
clientId?: string;
|
||||
clientStatus?: "pending" | "queued";
|
||||
queueState?: "queued";
|
||||
queueTargetRunId?: string | null;
|
||||
}
|
||||
|
||||
interface LinkedRunItem {
|
||||
|
|
@ -32,6 +37,7 @@ interface CommentReassignment {
|
|||
|
||||
interface CommentThreadProps {
|
||||
comments: CommentWithRunMeta[];
|
||||
queuedComments?: CommentWithRunMeta[];
|
||||
linkedRuns?: LinkedRunItem[];
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
|
|
@ -48,6 +54,8 @@ interface CommentThreadProps {
|
|||
currentAssigneeValue?: string;
|
||||
suggestedAssigneeValue?: string;
|
||||
mentions?: MentionOption[];
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
}
|
||||
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
|
|
@ -114,6 +122,122 @@ function CopyMarkdownButton({ text }: { text: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CommentCard({
|
||||
comment,
|
||||
agentMap,
|
||||
companyId,
|
||||
projectId,
|
||||
highlightCommentId,
|
||||
queued = false,
|
||||
}: {
|
||||
comment: CommentWithRunMeta;
|
||||
agentMap?: Map<string, Agent>;
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
highlightCommentId?: string | null;
|
||||
queued?: boolean;
|
||||
}) {
|
||||
const isHighlighted = highlightCommentId === comment.id;
|
||||
const isPending = comment.clientStatus === "pending";
|
||||
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
id={`comment-${comment.id}`}
|
||||
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
|
||||
isQueued
|
||||
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/10"
|
||||
: isHighlighted
|
||||
? "border-primary/50 bg-primary/5"
|
||||
: "border-border"
|
||||
} ${isPending ? "opacity-80" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
{comment.authorAgentId ? (
|
||||
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
|
||||
<Identity
|
||||
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
|
||||
size="sm"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Identity name="You" size="sm" />
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
{isQueued ? (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
Queued
|
||||
</span>
|
||||
) : null}
|
||||
{companyId && !isPending ? (
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["commentContextMenuItem"]}
|
||||
entityType="comment"
|
||||
context={{
|
||||
companyId,
|
||||
projectId: projectId ?? null,
|
||||
entityId: comment.id,
|
||||
entityType: "comment",
|
||||
parentEntityId: comment.issueId,
|
||||
}}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
itemClassName="inline-flex"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : null}
|
||||
{isPending ? (
|
||||
<span className="text-xs text-muted-foreground">{isQueued ? "Queueing..." : "Sending..."}</span>
|
||||
) : (
|
||||
<a
|
||||
href={`#comment-${comment.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||
>
|
||||
{formatDateTime(comment.createdAt)}
|
||||
</a>
|
||||
)}
|
||||
<CopyMarkdownButton text={comment.body} />
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
{companyId && !isPending ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["commentAnnotation"]}
|
||||
entityType="comment"
|
||||
context={{
|
||||
companyId,
|
||||
projectId: projectId ?? null,
|
||||
entityId: comment.id,
|
||||
entityType: "comment",
|
||||
parentEntityId: comment.issueId,
|
||||
}}
|
||||
className="space-y-2"
|
||||
itemClassName="rounded-md"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{comment.runId && !isPending ? (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
{comment.runAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineItem =
|
||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||
|
|
@ -168,86 +292,15 @@ const TimelineList = memo(function TimelineList({
|
|||
}
|
||||
|
||||
const comment = item.comment;
|
||||
const isHighlighted = highlightCommentId === comment.id;
|
||||
return (
|
||||
<div
|
||||
<CommentCard
|
||||
key={comment.id}
|
||||
id={`comment-${comment.id}`}
|
||||
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
{comment.authorAgentId ? (
|
||||
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
|
||||
<Identity
|
||||
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
|
||||
size="sm"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Identity name="You" size="sm" />
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
{companyId ? (
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["commentContextMenuItem"]}
|
||||
entityType="comment"
|
||||
context={{
|
||||
companyId,
|
||||
projectId: projectId ?? null,
|
||||
entityId: comment.id,
|
||||
entityType: "comment",
|
||||
parentEntityId: comment.issueId,
|
||||
}}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
itemClassName="inline-flex"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : null}
|
||||
<a
|
||||
href={`#comment-${comment.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||
>
|
||||
{formatDateTime(comment.createdAt)}
|
||||
</a>
|
||||
<CopyMarkdownButton text={comment.body} />
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
{companyId ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["commentAnnotation"]}
|
||||
entityType="comment"
|
||||
context={{
|
||||
companyId,
|
||||
projectId: projectId ?? null,
|
||||
entityId: comment.id,
|
||||
entityType: "comment",
|
||||
parentEntityId: comment.issueId,
|
||||
}}
|
||||
className="space-y-2"
|
||||
itemClassName="rounded-md"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{comment.runId && (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
{comment.runAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
comment={comment}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -256,6 +309,7 @@ const TimelineList = memo(function TimelineList({
|
|||
|
||||
export function CommentThread({
|
||||
comments,
|
||||
queuedComments = [],
|
||||
linkedRuns = [],
|
||||
companyId,
|
||||
projectId,
|
||||
|
|
@ -270,6 +324,8 @@ export function CommentThread({
|
|||
currentAssigneeValue = "",
|
||||
suggestedAssigneeValue,
|
||||
mentions: providedMentions,
|
||||
onInterruptQueued,
|
||||
interruptingQueuedRunId = null,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
|
|
@ -345,7 +401,7 @@ export function CommentThread({
|
|||
// Scroll to comment when URL hash matches #comment-{id}
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!hash.startsWith("#comment-") || comments.length === 0) return;
|
||||
if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return;
|
||||
const commentId = hash.slice("#comment-".length);
|
||||
// Only scroll once per hash
|
||||
if (hasScrolledRef.current) return;
|
||||
|
|
@ -358,21 +414,31 @@ export function CommentThread({
|
|||
const timer = setTimeout(() => setHighlightCommentId(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [location.hash, comments]);
|
||||
}, [location.hash, comments, queuedComments]);
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||
const submittedBody = trimmed;
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
|
||||
setBody("");
|
||||
// TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI.
|
||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: current,
|
||||
submittedBody,
|
||||
}),
|
||||
);
|
||||
// Parent mutation handlers surface the failure and the draft is restored for retry.
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
|
@ -401,18 +467,54 @@ export function CommentThread({
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Comments & Runs ({timeline.length})</h3>
|
||||
<h3 className="text-sm font-semibold">Comments & Runs ({timeline.length + queuedComments.length})</h3>
|
||||
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
/>
|
||||
{timeline.length > 0 ? (
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{liveRunSlot}
|
||||
|
||||
{queuedComments.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-amber-700 dark:text-amber-300">
|
||||
Queued Comments ({queuedComments.length})
|
||||
</h4>
|
||||
{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queuedComments[0].queueTargetRunId}
|
||||
onClick={() => void onInterruptQueued(queuedComments[0]!.queueTargetRunId!)}
|
||||
>
|
||||
{interruptingQueuedRunId === queuedComments[0].queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{queuedComments.map((comment) => (
|
||||
<CommentCard
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
queued
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
|
|||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
MouseSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
|
|
@ -244,7 +244,8 @@ export function CompanyRail() {
|
|||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
|
|
|||
314
ui/src/components/ExecutionWorkspaceCloseDialog.tsx
Normal file
314
ui/src/components/ExecutionWorkspaceCloseDialog.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatDateTime, issueUrl } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
|
||||
type ExecutionWorkspaceCloseDialogProps = {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
currentStatus: ExecutionWorkspace["status"];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClosed?: (workspace: ExecutionWorkspace) => void;
|
||||
};
|
||||
|
||||
function readinessTone(state: "ready" | "ready_with_warnings" | "blocked") {
|
||||
if (state === "blocked") {
|
||||
return "border-destructive/30 bg-destructive/5 text-destructive";
|
||||
}
|
||||
if (state === "ready_with_warnings") {
|
||||
return "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-300";
|
||||
}
|
||||
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
}
|
||||
|
||||
export function ExecutionWorkspaceCloseDialog({
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
currentStatus,
|
||||
open,
|
||||
onOpenChange,
|
||||
onClosed,
|
||||
}: ExecutionWorkspaceCloseDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace";
|
||||
|
||||
const readinessQuery = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.closeReadiness(workspaceId),
|
||||
queryFn: () => executionWorkspacesApi.getCloseReadiness(workspaceId),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const closeWorkspace = useMutation({
|
||||
mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }),
|
||||
onSuccess: (workspace) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(workspace.id), workspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(workspace.id) });
|
||||
pushToast({
|
||||
title: currentStatus === "cleanup_failed" ? "Workspace close retried" : "Workspace closed",
|
||||
tone: "success",
|
||||
});
|
||||
onOpenChange(false);
|
||||
onClosed?.(workspace);
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to close workspace",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const readiness = readinessQuery.data ?? null;
|
||||
const blockingIssues = readiness?.linkedIssues.filter((issue) => !issue.isTerminal) ?? [];
|
||||
const otherLinkedIssues = readiness?.linkedIssues.filter((issue) => issue.isTerminal) ?? [];
|
||||
const confirmDisabled =
|
||||
currentStatus === "archived" ||
|
||||
closeWorkspace.isPending ||
|
||||
readinessQuery.isLoading ||
|
||||
readiness == null ||
|
||||
readiness.state === "blocked";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
|
||||
}}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionLabel}</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
|
||||
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{readinessQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking whether this workspace is safe to close...
|
||||
</div>
|
||||
) : readinessQuery.error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
|
||||
</div>
|
||||
) : readiness ? (
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="font-medium">
|
||||
{readiness.state === "blocked"
|
||||
? "Close is blocked"
|
||||
: readiness.state === "ready_with_warnings"
|
||||
? "Close is allowed with warnings"
|
||||
: "Close is ready"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs opacity-80">
|
||||
{readiness.isSharedWorkspace
|
||||
? "This is a shared workspace session. Archiving it removes this session record but keeps the underlying project workspace."
|
||||
: readiness.git?.workspacePath && readiness.git.repoRoot && readiness.git.workspacePath !== readiness.git.repoRoot
|
||||
? "This execution workspace has its own checkout path and can be archived independently."
|
||||
: readiness.isProjectPrimaryWorkspace
|
||||
? "This execution workspace currently points at the project's primary workspace path."
|
||||
: "This workspace is disposable and can be archived."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{blockingIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking issues</h3>
|
||||
<div className="space-y-2">
|
||||
{blockingIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">{issue.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{readiness.blockingReasons.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking reasons</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.blockingReasons.map((reason, idx) => (
|
||||
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
|
||||
{reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{readiness.warnings.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Warnings</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.warnings.map((warning, idx) => (
|
||||
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{readiness.git ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Git status</h3>
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
||||
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
|
||||
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
|
||||
<div>{readiness.git.isMergedIntoBase == null ? "Unknown" : readiness.git.isMergedIntoBase ? "Yes" : "No"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Ahead / behind</div>
|
||||
<div>
|
||||
{(readiness.git.aheadCount ?? 0).toString()} / {(readiness.git.behindCount ?? 0).toString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Dirty tracked files</div>
|
||||
<div>{readiness.git.dirtyEntryCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Untracked files</div>
|
||||
<div>{readiness.git.untrackedEntryCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{otherLinkedIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
||||
<div className="space-y-2">
|
||||
{otherLinkedIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">{issue.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{readiness.runtimeServices.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{service.serviceName}</span>
|
||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||
</div>
|
||||
<div className="mt-1 break-words text-xs text-muted-foreground">
|
||||
{service.url ?? service.command ?? service.cwd ?? "No additional details"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.plannedActions.map((action, index) => (
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||
{action.command ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap break-all rounded-lg bg-background px-3 py-2 font-mono text-xs text-foreground">
|
||||
{action.command}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{currentStatus === "cleanup_failed" ? (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
|
||||
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
|
||||
workspace status if it succeeds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentStatus === "archived" ? (
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
This workspace is already archived.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{readiness.git?.repoRoot ? (
|
||||
<div className="break-words text-xs text-muted-foreground">
|
||||
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
|
||||
{readiness.git.workspacePath ? (
|
||||
<>
|
||||
{" · "}Workspace path: <span className="font-mono break-all">{readiness.git.workspacePath}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last checked {formatDateTime(new Date())}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={closeWorkspace.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentStatus === "cleanup_failed" ? "default" : "destructive"}
|
||||
onClick={() => closeWorkspace.mutate()}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{closeWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
43
ui/src/components/HermesIcon.tsx
Normal file
43
ui/src/components/HermesIcon.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { cn } from "../lib/utils";
|
||||
|
||||
interface HermesIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermes caduceus icon — winged staff with two intertwined serpents.
|
||||
* Replaces the generic Zap icon for the hermes_local adapter type.
|
||||
*
|
||||
* ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings.
|
||||
*/
|
||||
export function HermesIcon({ className }: HermesIconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn(className)}
|
||||
>
|
||||
{/* Central staff */}
|
||||
<line x1="12" y1="6" x2="12" y2="23" />
|
||||
{/* Left serpent curves */}
|
||||
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
|
||||
{/* Right serpent curves */}
|
||||
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
|
||||
{/* Snake heads facing outward */}
|
||||
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
|
||||
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
|
||||
{/* Wings at top of staff */}
|
||||
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
|
||||
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
|
||||
{/* Wing feather details */}
|
||||
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
|
||||
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
|
||||
{/* Staff sphere at top */}
|
||||
<circle cx="12" cy="6.5" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
|
|
@ -21,15 +19,9 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
|
|||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, Copy, Check } from "lucide-react";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
const EXECUTION_WORKSPACE_OPTIONS = [
|
||||
{ value: "shared_workspace", label: "Project default" },
|
||||
{ value: "isolated_workspace", label: "New isolated workspace" },
|
||||
{ value: "reuse_existing", label: "Reuse existing workspace" },
|
||||
] as const;
|
||||
|
||||
function defaultProjectWorkspaceIdForProject(project: {
|
||||
workspaces?: Array<{ id: string; isPrimary: boolean }>;
|
||||
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
|
||||
|
|
@ -48,23 +40,6 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
|||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function issueModeForExistingWorkspace(mode: string | null | undefined) {
|
||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
|
||||
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
|
||||
const persistedMode =
|
||||
issue.currentExecutionWorkspace?.mode
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? issue.executionWorkspacePreference;
|
||||
return Boolean(
|
||||
issue.executionWorkspaceId &&
|
||||
(persistedMode === "isolated_workspace" || persistedMode === "operator_branch"),
|
||||
);
|
||||
}
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
|
|
@ -142,49 +117,6 @@ function PropertyPicker({
|
|||
);
|
||||
}
|
||||
|
||||
/** Splits a string at `/` and `-` boundaries, inserting <wbr> for natural line breaks. */
|
||||
function BreakablePath({ text }: { text: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Split on path separators and hyphens, keeping them in the output
|
||||
const segments = text.split(/(?<=[\/-])/);
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
if (i > 0) parts.push(<wbr key={i} />);
|
||||
parts.push(segments[i]);
|
||||
}
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
/** Displays a value with a copy-to-clipboard icon and "Copied!" feedback. */
|
||||
function CopyableValue({ value, label, mono, className }: { value: string; label?: string; mono?: boolean; className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* noop */ }
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-start gap-1 group", className)}>
|
||||
<span className="min-w-0" style={{ overflowWrap: "anywhere" }}>
|
||||
{label && <span className="text-muted-foreground">{label} </span>}
|
||||
<span className={mono ? "font-mono" : undefined}><BreakablePath text={value} /></span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 mt-0.5 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -202,10 +134,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId;
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
|
|
@ -275,48 +203,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
const currentProject = issue.projectId
|
||||
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
||||
: null;
|
||||
const currentProjectExecutionWorkspacePolicy =
|
||||
experimentalSettings?.enableIsolatedWorkspaces === true
|
||||
? currentProject?.executionWorkspacePolicy ?? null
|
||||
: null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const { data: reusableExecutionWorkspaces } = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
|
||||
projectId: issue.projectId ?? undefined,
|
||||
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
queryFn: () =>
|
||||
executionWorkspacesApi.list(companyId!, {
|
||||
projectId: issue.projectId ?? undefined,
|
||||
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
enabled: Boolean(companyId) && Boolean(issue.projectId),
|
||||
});
|
||||
const deduplicatedReusableWorkspaces = useMemo(() => {
|
||||
const workspaces = reusableExecutionWorkspaces ?? [];
|
||||
const seen = new Map<string, typeof workspaces[number]>();
|
||||
for (const ws of workspaces) {
|
||||
const key = ws.cwd ?? ws.id;
|
||||
const existing = seen.get(key);
|
||||
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
|
||||
seen.set(key, ws);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}, [reusableExecutionWorkspaces]);
|
||||
const selectedReusableExecutionWorkspace =
|
||||
deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId)
|
||||
?? issue.currentExecutionWorkspace
|
||||
?? null;
|
||||
const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue)
|
||||
? "reuse_existing"
|
||||
: (
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(currentProject)
|
||||
);
|
||||
const projectLink = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
|
|
@ -674,93 +560,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||
{projectContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{currentProjectSupportsExecutionWorkspace && (
|
||||
<PropertyRow label="Workspace">
|
||||
<div className="w-full space-y-2">
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={currentExecutionWorkspaceSelection}
|
||||
onChange={(e) => {
|
||||
const nextMode = e.target.value;
|
||||
onUpdate({
|
||||
executionWorkspacePreference: nextMode,
|
||||
executionWorkspaceId: nextMode === "reuse_existing" ? issue.executionWorkspaceId : null,
|
||||
executionWorkspaceSettings: {
|
||||
mode:
|
||||
nextMode === "reuse_existing"
|
||||
? issueModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
|
||||
: nextMode,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
|
||||
? "Existing isolated workspace"
|
||||
: option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{currentExecutionWorkspaceSelection === "reuse_existing" && (
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={issue.executionWorkspaceId ?? ""}
|
||||
onChange={(e) => {
|
||||
const nextExecutionWorkspaceId = e.target.value || null;
|
||||
const nextExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||
(workspace) => workspace.id === nextExecutionWorkspaceId,
|
||||
);
|
||||
onUpdate({
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceId: nextExecutionWorkspaceId,
|
||||
executionWorkspaceSettings: {
|
||||
mode: issueModeForExistingWorkspace(nextExecutionWorkspace?.mode),
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Choose an existing workspace</option>
|
||||
{deduplicatedReusableWorkspaces.map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>
|
||||
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{issue.currentExecutionWorkspace && (
|
||||
<div className="text-[11px] text-muted-foreground space-y-0.5">
|
||||
<div style={{ overflowWrap: "anywhere" }}>
|
||||
Current:{" "}
|
||||
<Link
|
||||
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
<BreakablePath text={issue.currentExecutionWorkspace.name} />
|
||||
</Link>
|
||||
{" · "}
|
||||
{issue.currentExecutionWorkspace.status}
|
||||
</div>
|
||||
{issue.currentExecutionWorkspace.cwd && (
|
||||
<CopyableValue value={issue.currentExecutionWorkspace.cwd} mono className="text-[11px]" />
|
||||
)}
|
||||
{issue.currentExecutionWorkspace.branchName && (
|
||||
<CopyableValue value={issue.currentExecutionWorkspace.branchName} label="Branch:" className="text-[11px]" />
|
||||
)}
|
||||
{issue.currentExecutionWorkspace.repoUrl && (
|
||||
<CopyableValue value={issue.currentExecutionWorkspace.repoUrl} label="Repo:" mono className="text-[11px]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!issue.currentExecutionWorkspace && currentProject?.primaryWorkspace?.cwd && (
|
||||
<CopyableValue value={currentProject.primaryWorkspace.cwd} mono className="text-[11px] text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
|
|
|
|||
116
ui/src/components/IssueRow.test.tsx
Normal file
116
ui/src/components/IssueRow.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// @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 { IssueRow } from "./IssueRow";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => (
|
||||
<a className={className} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// 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",
|
||||
identifier: "PAP-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Inbox item",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 1,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
myLastTouchAt: null,
|
||||
lastExternalCommentAt: null,
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("IssueRow", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("suppresses accent hover styling when the row is selected", () => {
|
||||
const root = createRoot(container);
|
||||
const issue = createIssue();
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={issue} selected />);
|
||||
});
|
||||
|
||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.className).toContain("hover:bg-transparent");
|
||||
expect(link?.className).not.toContain("hover:bg-accent/50");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("neutralizes selected status and unread dot accents", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={createIssue()} selected unreadState="visible" />);
|
||||
});
|
||||
|
||||
const markReadButton = container.querySelector('button[aria-label="Mark as read"]');
|
||||
const unreadDot = markReadButton?.querySelector("span");
|
||||
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
|
||||
|
||||
expect(markReadButton).not.toBeNull();
|
||||
expect(markReadButton?.className).toContain("hover:bg-muted/80");
|
||||
expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20");
|
||||
expect(unreadDot).not.toBeNull();
|
||||
expect(unreadDot?.className).toContain("bg-muted-foreground/70");
|
||||
expect(unreadDot?.className).not.toContain("bg-blue-600");
|
||||
expect(statusIcon).not.toBeNull();
|
||||
expect(statusIcon?.className).toContain("!border-muted-foreground");
|
||||
expect(statusIcon?.className).toContain("!text-muted-foreground");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { X } from "lucide-react";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
|
|
@ -9,6 +11,7 @@ type UnreadState = "hidden" | "visible" | "fading";
|
|||
interface IssueRowProps {
|
||||
issue: Issue;
|
||||
issueLinkState?: unknown;
|
||||
selected?: boolean;
|
||||
mobileLeading?: ReactNode;
|
||||
desktopMetaLeading?: ReactNode;
|
||||
desktopLeadingSpacer?: boolean;
|
||||
|
|
@ -17,12 +20,15 @@ interface IssueRowProps {
|
|||
trailingMeta?: ReactNode;
|
||||
unreadState?: UnreadState | null;
|
||||
onMarkRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveDisabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IssueRow({
|
||||
issue,
|
||||
issueLinkState,
|
||||
selected = false,
|
||||
mobileLeading,
|
||||
desktopMetaLeading,
|
||||
desktopLeadingSpacer = false,
|
||||
|
|
@ -31,24 +37,29 @@ export function IssueRow({
|
|||
trailingMeta,
|
||||
unreadState = null,
|
||||
onMarkRead,
|
||||
onArchive,
|
||||
archiveDisabled,
|
||||
className,
|
||||
}: IssueRowProps) {
|
||||
const issuePathId = issue.identifier ?? issue.id;
|
||||
const identifier = issue.identifier ?? issue.id.slice(0, 8);
|
||||
const showUnreadSlot = unreadState !== null;
|
||||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/issues/${issuePathId}`}
|
||||
to={createIssueDetailPath(issuePathId, issueLinkState)}
|
||||
state={issueLinkState}
|
||||
data-inbox-issue-link
|
||||
className={cn(
|
||||
"flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} />}
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
|
||||
|
|
@ -61,7 +72,7 @@ export function IssueRow({
|
|||
{desktopMetaLeading ?? (
|
||||
<>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} />
|
||||
<StatusIcon status={issue.status} className={selectedStatusClass} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{identifier}
|
||||
|
|
@ -103,16 +114,40 @@ export function IssueRow({
|
|||
onMarkRead?.();
|
||||
}
|
||||
}}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||
selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20",
|
||||
)}
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : onArchive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
disabled={archiveDisabled}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
||||
aria-label="Dismiss from inbox"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
|
|
|
|||
445
ui/src/components/IssueWorkspaceCard.tsx
Normal file
445
ui/src/components/IssueWorkspaceCard.tsx
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Issue, ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility helpers (mirrored from IssueProperties for self-containment) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const EXECUTION_WORKSPACE_OPTIONS = [
|
||||
{ value: "shared_workspace", label: "Project default" },
|
||||
{ value: "isolated_workspace", label: "New isolated workspace" },
|
||||
{ value: "reuse_existing", label: "Reuse existing workspace" },
|
||||
] as const;
|
||||
|
||||
function issueModeForExistingWorkspace(mode: string | null | undefined) {
|
||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
|
||||
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
|
||||
const persistedMode =
|
||||
issue.currentExecutionWorkspace?.mode
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? issue.executionWorkspacePreference;
|
||||
return Boolean(
|
||||
issue.executionWorkspaceId &&
|
||||
(persistedMode === "isolated_workspace" || persistedMode === "operator_branch"),
|
||||
);
|
||||
}
|
||||
|
||||
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
|
||||
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
|
||||
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
|
||||
if (defaultMode === "adapter_default") return "agent_default";
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Sub-components */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function BreakablePath({ text }: { text: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const segments = text.split(/(?<=[\/-])/);
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
if (i > 0) parts.push(<wbr key={i} />);
|
||||
parts.push(segments[i]);
|
||||
}
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
function CopyableInline({ value, label, mono }: { value: string; label?: string; mono?: boolean }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* noop */ }
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 group/copy">
|
||||
{label && <span className="text-muted-foreground">{label}</span>}
|
||||
<span className={cn("min-w-0", mono && "font-mono")} style={{ overflowWrap: "anywhere" }}>
|
||||
<BreakablePath text={value} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover/copy:opacity-100 focus:opacity-100"
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied!" : "Copy"}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function workspaceModeLabel(mode: string | null | undefined) {
|
||||
switch (mode) {
|
||||
case "isolated_workspace": return "Isolated workspace";
|
||||
case "operator_branch": return "Operator branch";
|
||||
case "cloud_sandbox": return "Cloud sandbox";
|
||||
case "adapter_managed": return "Adapter managed";
|
||||
default: return "Workspace";
|
||||
}
|
||||
}
|
||||
|
||||
function configuredWorkspaceLabel(
|
||||
selection: string | null | undefined,
|
||||
reusableWorkspace: ExecutionWorkspace | null,
|
||||
) {
|
||||
switch (selection) {
|
||||
case "isolated_workspace":
|
||||
return "New isolated workspace";
|
||||
case "reuse_existing":
|
||||
return reusableWorkspace?.mode === "isolated_workspace"
|
||||
? "Existing isolated workspace"
|
||||
: "Reuse existing workspace";
|
||||
default:
|
||||
return "Project default";
|
||||
}
|
||||
}
|
||||
|
||||
function projectWorkspaceDetailLink(input: {
|
||||
projectId: string | null | undefined;
|
||||
projectWorkspaceId: string | null | undefined;
|
||||
}) {
|
||||
if (!input.projectId || !input.projectWorkspaceId) return null;
|
||||
return projectWorkspaceUrl({ id: input.projectId, urlKey: input.projectId }, input.projectWorkspaceId);
|
||||
}
|
||||
|
||||
function workspaceDetailLink(input: {
|
||||
projectId: string | null | undefined;
|
||||
issueProjectWorkspaceId: string | null | undefined;
|
||||
workspace: ExecutionWorkspace | null | undefined;
|
||||
}) {
|
||||
const linkedProjectWorkspaceId = input.workspace?.projectWorkspaceId ?? input.issueProjectWorkspaceId ?? null;
|
||||
if (input.workspace?.mode === "shared_workspace") {
|
||||
return projectWorkspaceDetailLink({
|
||||
projectId: input.projectId,
|
||||
projectWorkspaceId: linkedProjectWorkspaceId,
|
||||
});
|
||||
}
|
||||
return input.workspace ? `/execution-workspaces/${input.workspace.id}` : null;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const colors: Record<string, string> = {
|
||||
active: "bg-green-500/15 text-green-700 dark:text-green-400",
|
||||
idle: "bg-muted text-muted-foreground",
|
||||
in_review: "bg-blue-500/15 text-blue-700 dark:text-blue-400",
|
||||
archived: "bg-muted text-muted-foreground",
|
||||
};
|
||||
return (
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium", colors[status] ?? colors.idle)}>
|
||||
{status.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Main component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface IssueWorkspaceCardProps {
|
||||
issue: Issue;
|
||||
project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceCardProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
|
||||
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
||||
&& Boolean(project?.executionWorkspacePolicy?.enabled);
|
||||
|
||||
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
|
||||
|
||||
const { data: reusableExecutionWorkspaces } = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
|
||||
projectId: issue.projectId ?? undefined,
|
||||
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
queryFn: () =>
|
||||
executionWorkspacesApi.list(companyId!, {
|
||||
projectId: issue.projectId ?? undefined,
|
||||
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
enabled: Boolean(companyId) && Boolean(issue.projectId) && editing,
|
||||
});
|
||||
|
||||
const deduplicatedReusableWorkspaces = useMemo(() => {
|
||||
const workspaces = reusableExecutionWorkspaces ?? [];
|
||||
const seen = new Map<string, typeof workspaces[number]>();
|
||||
for (const ws of workspaces) {
|
||||
const key = ws.cwd ?? ws.id;
|
||||
const existing = seen.get(key);
|
||||
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
|
||||
seen.set(key, ws);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}, [reusableExecutionWorkspaces]);
|
||||
|
||||
const selectedReusableExecutionWorkspace =
|
||||
deduplicatedReusableWorkspaces.find((w) => w.id === issue.executionWorkspaceId)
|
||||
?? workspace
|
||||
?? null;
|
||||
|
||||
const currentSelection = shouldPresentExistingWorkspaceSelection(issue)
|
||||
? "reuse_existing"
|
||||
: (
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(project)
|
||||
);
|
||||
|
||||
const [draftSelection, setDraftSelection] = useState(currentSelection);
|
||||
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
setDraftSelection(currentSelection);
|
||||
setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? "");
|
||||
}, [currentSelection, editing, issue.executionWorkspaceId]);
|
||||
|
||||
const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace");
|
||||
|
||||
const configuredReusableWorkspace =
|
||||
deduplicatedReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId)
|
||||
?? (draftExecutionWorkspaceId === issue.executionWorkspaceId ? selectedReusableExecutionWorkspace : null);
|
||||
|
||||
const selectedReusableWorkspaceLink = workspaceDetailLink({
|
||||
projectId: project?.id,
|
||||
issueProjectWorkspaceId: issue.projectWorkspaceId,
|
||||
workspace: selectedReusableExecutionWorkspace,
|
||||
});
|
||||
const currentWorkspaceLink = workspaceDetailLink({
|
||||
projectId: project?.id,
|
||||
issueProjectWorkspaceId: issue.projectWorkspaceId,
|
||||
workspace,
|
||||
});
|
||||
|
||||
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSaveWorkspaceConfig) return;
|
||||
onUpdate({
|
||||
executionWorkspacePreference: draftSelection,
|
||||
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
|
||||
executionWorkspaceSettings: {
|
||||
mode:
|
||||
draftSelection === "reuse_existing"
|
||||
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
|
||||
: draftSelection,
|
||||
},
|
||||
});
|
||||
setEditing(false);
|
||||
}, [
|
||||
canSaveWorkspaceConfig,
|
||||
configuredReusableWorkspace?.mode,
|
||||
draftExecutionWorkspaceId,
|
||||
draftSelection,
|
||||
onUpdate,
|
||||
]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDraftSelection(currentSelection);
|
||||
setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? "");
|
||||
setEditing(false);
|
||||
}, [currentSelection, issue.executionWorkspaceId]);
|
||||
|
||||
if (!policyEnabled || !project) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{activeNonDefaultWorkspace && workspace
|
||||
? workspaceModeLabel(workspace.mode)
|
||||
: configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)}
|
||||
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={handleSave}
|
||||
disabled={!canSaveWorkspaceConfig}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-1" />Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only info */}
|
||||
{!editing && (
|
||||
<div className="space-y-1.5 text-xs">
|
||||
{workspace?.branchName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<CopyableInline value={workspace.branchName} mono />
|
||||
</div>
|
||||
)}
|
||||
{workspace?.cwd && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FolderOpen className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<CopyableInline value={workspace.cwd} mono />
|
||||
</div>
|
||||
)}
|
||||
{workspace?.repoUrl && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span className="text-[11px]">Repo:</span>
|
||||
<CopyableInline value={workspace.repoUrl} mono />
|
||||
</div>
|
||||
)}
|
||||
{!workspace && (
|
||||
<div className="text-muted-foreground">
|
||||
{currentSelection === "isolated_workspace"
|
||||
? "A fresh isolated workspace will be created when this issue runs."
|
||||
: currentSelection === "reuse_existing"
|
||||
? "This issue will reuse an existing workspace when it runs."
|
||||
: "This issue will use the project default workspace configuration when it runs."}
|
||||
</div>
|
||||
)}
|
||||
{currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && (
|
||||
<div className="text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
|
||||
Reusing:{" "}
|
||||
{selectedReusableWorkspaceLink ? (
|
||||
<Link
|
||||
to={selectedReusableWorkspaceLink}
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
|
||||
</Link>
|
||||
) : (
|
||||
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{workspace && currentWorkspaceLink && (
|
||||
<div className="pt-0.5">
|
||||
<Link
|
||||
to={currentWorkspaceLink}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
View workspace details →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editing controls */}
|
||||
{editing && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={draftSelection}
|
||||
onChange={(e) => {
|
||||
const nextMode = e.target.value;
|
||||
setDraftSelection(nextMode);
|
||||
if (nextMode !== "reuse_existing") {
|
||||
setDraftExecutionWorkspaceId("");
|
||||
} else if (!draftExecutionWorkspaceId && issue.executionWorkspaceId) {
|
||||
setDraftExecutionWorkspaceId(issue.executionWorkspaceId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.value === "reuse_existing" && configuredReusableWorkspace?.mode === "isolated_workspace"
|
||||
? "Existing isolated workspace"
|
||||
: option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{draftSelection === "reuse_existing" && (
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={draftExecutionWorkspaceId}
|
||||
onChange={(e) => {
|
||||
setDraftExecutionWorkspaceId(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Choose an existing workspace</option>
|
||||
{deduplicatedReusableWorkspaces.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name} · {w.status} · {w.branchName ?? w.cwd ?? w.id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Current workspace summary when editing */}
|
||||
{workspace && (
|
||||
<div className="text-[11px] text-muted-foreground space-y-0.5 pt-1 border-t border-border/50">
|
||||
<div style={{ overflowWrap: "anywhere" }}>
|
||||
Current:{" "}
|
||||
{currentWorkspaceLink ? (
|
||||
<Link
|
||||
to={currentWorkspaceLink}
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
<BreakablePath text={workspace.name} />
|
||||
</Link>
|
||||
) : (
|
||||
<BreakablePath text={workspace.name} />
|
||||
)}
|
||||
{" · "}
|
||||
{workspace.status}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
|
|
@ -68,6 +68,7 @@ const quickFilterPresets = [
|
|||
{ label: "Backlog", statuses: ["backlog"] },
|
||||
{ label: "Done", statuses: ["done", "cancelled"] },
|
||||
];
|
||||
const ISSUE_SEARCH_COMMIT_DELAY_MS = 150;
|
||||
|
||||
function getViewState(key: string): IssueViewState {
|
||||
try {
|
||||
|
|
@ -174,6 +175,44 @@ interface IssuesListProps {
|
|||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface IssuesSearchInputProps {
|
||||
initialValue: string;
|
||||
onValueCommitted: (value: string) => void;
|
||||
}
|
||||
|
||||
function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const onValueCommittedRef = useRef(onValueCommitted);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
useEffect(() => {
|
||||
onValueCommittedRef.current = onValueCommitted;
|
||||
}, [onValueCommitted]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
onValueCommittedRef.current(value);
|
||||
}, ISSUE_SEARCH_COMMIT_DELAY_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="relative w-48 sm:w-64 md:w-80">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Search issues..."
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search issues"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssuesList({
|
||||
issues,
|
||||
isLoading,
|
||||
|
|
@ -210,20 +249,12 @@ export function IssuesList({
|
|||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const [debouncedIssueSearch, setDebouncedIssueSearch] = useState(issueSearch);
|
||||
const normalizedIssueSearch = debouncedIssueSearch.trim();
|
||||
const normalizedIssueSearch = issueSearch.trim();
|
||||
|
||||
useEffect(() => {
|
||||
setIssueSearch(initialSearch ?? "");
|
||||
}, [initialSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDebouncedIssueSearch(issueSearch);
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [issueSearch]);
|
||||
|
||||
// Reload view state from localStorage when company changes (scopedKey changes).
|
||||
const prevScopedKey = useRef(scopedKey);
|
||||
useEffect(() => {
|
||||
|
|
@ -235,6 +266,13 @@ export function IssuesList({
|
|||
}
|
||||
}, [scopedKey, initialAssignees]);
|
||||
|
||||
const handleIssueSearchCommit = useCallback((nextSearch: string) => {
|
||||
startTransition(() => {
|
||||
setIssueSearch(nextSearch);
|
||||
});
|
||||
onSearchChange?.(nextSearch);
|
||||
}, [onSearchChange]);
|
||||
|
||||
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
||||
setViewState((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
|
|
@ -250,6 +288,7 @@ export function IssuesList({
|
|||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
|
||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const agentName = useCallback((id: string | null) => {
|
||||
|
|
@ -333,19 +372,10 @@ export function IssuesList({
|
|||
<Plus className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">New Issue</span>
|
||||
</Button>
|
||||
<div className="relative w-48 sm:w-64 md:w-80">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={issueSearch}
|
||||
onChange={(e) => {
|
||||
setIssueSearch(e.target.value);
|
||||
onSearchChange?.(e.target.value);
|
||||
}}
|
||||
placeholder="Search issues..."
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search issues"
|
||||
/>
|
||||
</div>
|
||||
<IssuesSearchInput
|
||||
initialValue={initialSearch ?? ""}
|
||||
onValueCommitted={handleIssueSearchCommit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
useState,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
CodeMirrorEditor,
|
||||
MDXEditor,
|
||||
|
|
@ -82,6 +83,9 @@ interface MentionState {
|
|||
query: string;
|
||||
top: number;
|
||||
left: number;
|
||||
/** Viewport-relative coords for portal positioning */
|
||||
viewportTop: number;
|
||||
viewportLeft: number;
|
||||
textNode: Text;
|
||||
atPos: number;
|
||||
endPos: number;
|
||||
|
|
@ -155,6 +159,8 @@ function detectMention(container: HTMLElement): MentionState | null {
|
|||
query,
|
||||
top: rect.bottom - containerRect.top,
|
||||
left: rect.left - containerRect.left,
|
||||
viewportTop: rect.bottom,
|
||||
viewportLeft: rect.left,
|
||||
textNode: textNode as Text,
|
||||
atPos,
|
||||
endPos: offset,
|
||||
|
|
@ -554,46 +560,51 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
plugins={plugins}
|
||||
/>
|
||||
|
||||
{/* Mention dropdown */}
|
||||
{mentionActive && filteredMentions.length > 0 && (
|
||||
<div
|
||||
className="absolute z-50 min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
style={{ top: mentionState.top + 4, left: mentionState.left }}
|
||||
>
|
||||
{filteredMentions.map((option, i) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
||||
i === mentionIndex && "bg-accent",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
selectMention(option);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
{option.kind === "project" && option.projectId ? (
|
||||
<span
|
||||
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
||||
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
||||
/>
|
||||
) : (
|
||||
<AgentIcon
|
||||
icon={option.agentIcon}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<span>{option.name}</span>
|
||||
{option.kind === "project" && option.projectId && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Project
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */}
|
||||
{mentionActive && filteredMentions.length > 0 &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
style={{
|
||||
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
|
||||
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
|
||||
}}
|
||||
>
|
||||
{filteredMentions.map((option, i) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
||||
i === mentionIndex && "bg-accent",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
selectMention(option);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
{option.kind === "project" && option.projectId ? (
|
||||
<span
|
||||
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
||||
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
||||
/>
|
||||
) : (
|
||||
<AgentIcon
|
||||
icon={option.agentIcon}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<span>{option.name}</span>
|
||||
{option.kind === "project" && option.projectId && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Project
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{isDragOver && canDropImage && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
type AdvancedAdapterType =
|
||||
| "claude_local"
|
||||
|
|
@ -29,7 +30,8 @@ type AdvancedAdapterType =
|
|||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "openclaw_gateway";
|
||||
| "openclaw_gateway"
|
||||
| "hermes_local";
|
||||
|
||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||
value: AdvancedAdapterType;
|
||||
|
|
@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
|||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "hermes_local",
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "pi_local",
|
||||
label: "Pi",
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ const ISSUE_THINKING_EFFORT_OPTIONS = {
|
|||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "X-High" },
|
||||
],
|
||||
opencode_local: [
|
||||
{ value: "", label: "Default" },
|
||||
|
|
@ -106,6 +107,7 @@ const ISSUE_THINKING_EFFORT_OPTIONS = {
|
|||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "X-High" },
|
||||
{ value: "max", label: "Max" },
|
||||
],
|
||||
} as const;
|
||||
|
|
@ -424,6 +426,7 @@ export function NewIssueDialog() {
|
|||
},
|
||||
onSuccess: ({ issue, companyId, failures }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
|
@ -32,7 +33,7 @@ import {
|
|||
} from "@/components/ui/tooltip";
|
||||
import { PROJECT_COLORS } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
|
||||
|
|
@ -68,6 +69,29 @@ export function NewProjectDialog() {
|
|||
enabled: !!selectedCompanyId && newProjectOpen,
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newProjectOpen,
|
||||
});
|
||||
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent",
|
||||
agentId: agent.id,
|
||||
agentIcon: agent.icon,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [agents]);
|
||||
|
||||
const createProject = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
projectsApi.create(selectedCompanyId!, data),
|
||||
|
|
@ -250,6 +274,7 @@ export function NewProjectDialog() {
|
|||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
|
|
|
|||
|
|
@ -56,12 +56,14 @@ import {
|
|||
ChevronDown,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
type AdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "gemini_local"
|
||||
| "hermes_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
|
|
@ -208,6 +210,7 @@ export function OnboardingWizard() {
|
|||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
|
|
@ -217,6 +220,8 @@ export function OnboardingWizard() {
|
|||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
|
|
@ -325,7 +330,8 @@ export function OnboardingWizard() {
|
|||
command,
|
||||
args,
|
||||
url,
|
||||
dangerouslySkipPermissions: adapterType === "claude_local",
|
||||
dangerouslySkipPermissions:
|
||||
adapterType === "claude_local" || adapterType === "opencode_local",
|
||||
dangerouslyBypassSandbox:
|
||||
adapterType === "codex_local"
|
||||
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
|
||||
|
|
@ -842,6 +848,12 @@ export function OnboardingWizard() {
|
|||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent"
|
||||
},
|
||||
{
|
||||
value: "hermes_local" as const,
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
|
|
@ -901,6 +913,7 @@ export function OnboardingWizard() {
|
|||
{(adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor") && (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
MouseSensor,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
useSensor,
|
||||
|
|
@ -153,7 +153,8 @@ export function SidebarProjects() {
|
|||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
149
ui/src/components/SwipeToArchive.test.tsx
Normal file
149
ui/src/components/SwipeToArchive.test.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SwipeToArchive } from "./SwipeToArchive";
|
||||
|
||||
// Tell React this environment uses act() for event flushing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function dispatchTouchEvent(
|
||||
node: Element,
|
||||
type: "touchstart" | "touchmove" | "touchend",
|
||||
coords: { x: number; y: number },
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
const touchPoint = { clientX: coords.x, clientY: coords.y };
|
||||
|
||||
Object.defineProperty(event, "touches", {
|
||||
configurable: true,
|
||||
value: type === "touchend" ? [] : [touchPoint],
|
||||
});
|
||||
Object.defineProperty(event, "changedTouches", {
|
||||
configurable: true,
|
||||
value: [touchPoint],
|
||||
});
|
||||
|
||||
node.dispatchEvent(event);
|
||||
}
|
||||
|
||||
describe("SwipeToArchive", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("suppresses descendant clicks after a horizontal swipe and archives the row", () => {
|
||||
const onArchive = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SwipeToArchive onArchive={onArchive}>
|
||||
<button type="button" onClick={onClick}>
|
||||
Open issue
|
||||
</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
const wrapper = container.firstElementChild as HTMLDivElement;
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 });
|
||||
Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 });
|
||||
|
||||
act(() => {
|
||||
dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 });
|
||||
});
|
||||
act(() => {
|
||||
dispatchTouchEvent(wrapper, "touchmove", { x: 80, y: 22 });
|
||||
});
|
||||
act(() => {
|
||||
dispatchTouchEvent(wrapper, "touchend", { x: 80, y: 22 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(140);
|
||||
});
|
||||
|
||||
expect(onArchive).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not suppress a normal tap click", () => {
|
||||
const onArchive = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SwipeToArchive onArchive={onArchive}>
|
||||
<button type="button" onClick={onClick}>
|
||||
Open issue
|
||||
</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onArchive).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the selected inbox treatment on the swipe surface", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SwipeToArchive onArchive={() => {}} selected>
|
||||
<button type="button">Open issue</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null;
|
||||
expect(surface).not.toBeNull();
|
||||
expect(surface?.className).toContain("bg-zinc-100");
|
||||
expect(surface?.className).toContain("dark:bg-zinc-800");
|
||||
expect(surface?.className).not.toContain("bg-card");
|
||||
expect(surface?.style.backgroundColor).toBe("");
|
||||
expect(surface?.style.boxShadow).toBe("");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
167
ui/src/components/SwipeToArchive.tsx
Normal file
167
ui/src/components/SwipeToArchive.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Archive } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface SwipeToArchiveProps {
|
||||
children: ReactNode;
|
||||
onArchive: () => void;
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COMMIT_THRESHOLD = 0.32;
|
||||
const MAX_SWIPE = 0.88;
|
||||
const COMMIT_DELAY_MS = 140;
|
||||
|
||||
export function SwipeToArchive({
|
||||
children,
|
||||
onArchive,
|
||||
disabled = false,
|
||||
selected = false,
|
||||
className,
|
||||
}: SwipeToArchiveProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const widthRef = useRef(0);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isCollapsing, setIsCollapsing] = useState(false);
|
||||
const [lockedHeight, setLockedHeight] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reset = () => {
|
||||
startPointRef.current = null;
|
||||
setIsDragging(false);
|
||||
setOffsetX(0);
|
||||
};
|
||||
|
||||
const commitArchive = () => {
|
||||
const node = containerRef.current;
|
||||
if (!node) {
|
||||
onArchive();
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
setLockedHeight(node.offsetHeight);
|
||||
setOffsetX(-Math.max(widthRef.current, node.offsetWidth));
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setIsCollapsing(true);
|
||||
});
|
||||
});
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
onArchive();
|
||||
}, COMMIT_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disabled || event.touches.length !== 1) return;
|
||||
const touch = event.touches[0];
|
||||
const node = containerRef.current;
|
||||
widthRef.current = node?.offsetWidth ?? 0;
|
||||
setLockedHeight(node?.offsetHeight ?? null);
|
||||
setIsCollapsing(false);
|
||||
suppressClickRef.current = false;
|
||||
startPointRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disabled || isCollapsing) return;
|
||||
const startPoint = startPointRef.current;
|
||||
if (!startPoint || event.touches.length !== 1) return;
|
||||
|
||||
const touch = event.touches[0];
|
||||
const deltaX = touch.clientX - startPoint.x;
|
||||
const deltaY = touch.clientY - startPoint.y;
|
||||
|
||||
if (!isDragging) {
|
||||
if (Math.abs(deltaX) < 6) return;
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
startPointRef.current = null;
|
||||
return;
|
||||
}
|
||||
suppressClickRef.current = true;
|
||||
}
|
||||
|
||||
if (deltaX >= 0) {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
setOffsetX(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxSwipe = widthRef.current > 0 ? widthRef.current * MAX_SWIPE : Number.POSITIVE_INFINITY;
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
setOffsetX(Math.max(deltaX, -maxSwipe));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (disabled || isCollapsing) return;
|
||||
const shouldCommit =
|
||||
widthRef.current > 0 && Math.abs(offsetX) >= widthRef.current * COMMIT_THRESHOLD;
|
||||
if (shouldCommit) {
|
||||
commitArchive();
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
};
|
||||
|
||||
const archiveReveal = widthRef.current > 0 ? Math.min(Math.abs(offsetX) / widthRef.current, 1) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative overflow-hidden touch-pan-y", className)}
|
||||
style={{
|
||||
height: lockedHeight === null ? undefined : isCollapsing ? 0 : lockedHeight,
|
||||
opacity: isCollapsing ? 0 : 1,
|
||||
transition: isCollapsing ? "height 200ms ease, opacity 200ms ease" : undefined,
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
onClickCapture={(event) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressClickRef.current = false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-end bg-emerald-600 px-4 text-white"
|
||||
style={{ opacity: Math.max(archiveReveal, 0.2) }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-inbox-row-surface
|
||||
className={cn(
|
||||
"relative will-change-transform",
|
||||
selected ? "bg-zinc-100 dark:bg-zinc-800" : "bg-card",
|
||||
)}
|
||||
style={{
|
||||
transform: `translate3d(${offsetX}px, 0, 0)`,
|
||||
transition: isDragging ? "none" : "transform 180ms ease-out",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ export const help: Record<string, string> = {
|
|||
model: "Override the default model used by the adapter.",
|
||||
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
||||
chrome: "Enable Claude's Chrome integration by passing --chrome.",
|
||||
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
||||
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
|
||||
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
||||
search: "Enable Codex web search capability during runs.",
|
||||
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
|
||||
|
|
@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
|
|||
opencode_local: "OpenCode (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
|
@ -104,11 +105,13 @@ export function ToggleField({
|
|||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
toggleTestId,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
toggleTestId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -118,6 +121,8 @@ export function ToggleField({
|
|||
</div>
|
||||
<button
|
||||
data-slot="toggle"
|
||||
data-testid={toggleTestId}
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
checked ? "bg-green-600" : "bg-muted"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,26 @@ type TranscriptBlock =
|
|||
status: "running" | "completed" | "error";
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "tool_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
items: Array<{
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
status: "running" | "completed" | "error";
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "stderr_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
lines: Array<{ ts: string; text: string }>;
|
||||
}
|
||||
| {
|
||||
type: "stdout";
|
||||
ts: string;
|
||||
|
|
@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
|||
return grouped;
|
||||
}
|
||||
|
||||
/** Group consecutive non-command tool blocks into a single tool_group accordion. */
|
||||
function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||
const grouped: TranscriptBlock[] = [];
|
||||
let pending: Array<Extract<TranscriptBlock, { type: "tool_group" }>["items"][number]> = [];
|
||||
let groupTs: string | null = null;
|
||||
let groupEndTs: string | undefined;
|
||||
|
||||
const flush = () => {
|
||||
if (pending.length === 0 || !groupTs) return;
|
||||
grouped.push({
|
||||
type: "tool_group",
|
||||
ts: groupTs,
|
||||
endTs: groupEndTs,
|
||||
items: pending,
|
||||
});
|
||||
pending = [];
|
||||
groupTs = null;
|
||||
groupEndTs = undefined;
|
||||
};
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === "tool" && !isCommandTool(block.name, block.input)) {
|
||||
if (!groupTs) groupTs = block.ts;
|
||||
groupEndTs = block.endTs ?? block.ts;
|
||||
pending.push({
|
||||
ts: block.ts,
|
||||
endTs: block.endTs,
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
result: block.result,
|
||||
isError: block.isError,
|
||||
status: block.status,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
grouped.push(block);
|
||||
}
|
||||
flush();
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
||||
const blocks: TranscriptBlock[] = [];
|
||||
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
||||
|
|
@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
if (shouldHideNiceModeStderr(entry.text)) {
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: "event",
|
||||
ts: entry.ts,
|
||||
label: "stderr",
|
||||
tone: "error",
|
||||
text: entry.text,
|
||||
});
|
||||
// Batch consecutive stderr entries into a single group
|
||||
const prev = blocks[blocks.length - 1];
|
||||
if (prev && prev.type === "stderr_group") {
|
||||
prev.lines.push({ ts: entry.ts, text: entry.text });
|
||||
prev.endTs = entry.ts;
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "stderr_group",
|
||||
ts: entry.ts,
|
||||
endTs: entry.ts,
|
||||
lines: [{ ts: entry.ts, text: entry.text }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
}
|
||||
}
|
||||
|
||||
return groupCommandBlocks(blocks);
|
||||
return groupToolBlocks(groupCommandBlocks(blocks));
|
||||
}
|
||||
|
||||
function TranscriptMessageBlock({
|
||||
|
|
@ -805,6 +873,139 @@ function TranscriptCommandGroup({
|
|||
);
|
||||
}
|
||||
|
||||
function TranscriptToolGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "tool_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
|
||||
const hasError = block.items.some((item) => item.status === "error");
|
||||
const isRunning = Boolean(runningItem);
|
||||
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
|
||||
const toolLabel =
|
||||
uniqueNames.length === 1
|
||||
? humanizeLabel(uniqueNames[0])
|
||||
: `${uniqueNames.length} tools`;
|
||||
const title = isRunning
|
||||
? `Using ${toolLabel}`
|
||||
: block.items.length === 1
|
||||
? `Used ${toolLabel}`
|
||||
: `Used ${toolLabel} (${block.items.length} calls)`;
|
||||
const subtitle = runningItem
|
||||
? summarizeToolInput(runningItem.name, runningItem.input, density)
|
||||
: null;
|
||||
const statusTone = isRunning
|
||||
? "text-cyan-700 dark:text-cyan-300"
|
||||
: "text-foreground/70";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-muted/[0.25]">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn("flex cursor-pointer gap-2 px-3 py-2.5", subtitle ? "items-start" : "items-center")}
|
||||
onClick={() => { if (hasSelectedText()) return; setOpen((v) => !v); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}>
|
||||
{block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => {
|
||||
const isItemRunning = item.status === "running";
|
||||
const isItemError = item.status === "error";
|
||||
return (
|
||||
<span
|
||||
key={`${item.ts}-${index}`}
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm",
|
||||
index > 0 && "-ml-1.5",
|
||||
isItemRunning
|
||||
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
|
||||
: isItemError
|
||||
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
|
||||
: "border-border/70 bg-background text-foreground/55",
|
||||
isItemRunning && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn("font-semibold uppercase leading-none tracking-[0.1em]", compact ? "text-[10px]" : "text-[11px]", "text-muted-foreground/70")}>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground", subtitle && "mt-0.5")}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||
aria-label={open ? "Collapse tool details" : "Expand tool details"}
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className={cn("space-y-2 border-t border-border/30 px-3 py-3", hasError && "rounded-b-xl")}>
|
||||
{block.items.map((item, index) => (
|
||||
<div key={`${item.ts}-${index}`} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
|
||||
item.status === "error"
|
||||
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
|
||||
: item.status === "running"
|
||||
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
|
||||
: "border-border/70 bg-background text-foreground/55",
|
||||
)}>
|
||||
<Wrench className="h-3 w-3" />
|
||||
</span>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground")}>
|
||||
{humanizeLabel(item.name)}
|
||||
</span>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]",
|
||||
item.status === "running" ? "text-cyan-700 dark:text-cyan-300"
|
||||
: item.status === "error" ? "text-red-700 dark:text-red-300"
|
||||
: "text-emerald-700 dark:text-emerald-300"
|
||||
)}>
|
||||
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Input</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80">
|
||||
{formatToolPayload(item.input) || "<empty>"}
|
||||
</pre>
|
||||
</div>
|
||||
{item.result && (
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Result</div>
|
||||
<pre className={cn(
|
||||
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
|
||||
item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
|
||||
)}>
|
||||
{formatToolPayload(item.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptActivityRow({
|
||||
block,
|
||||
density,
|
||||
|
|
@ -883,6 +1084,43 @@ function TranscriptEventRow({
|
|||
);
|
||||
}
|
||||
|
||||
function TranscriptStderrGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "stderr_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] p-2 text-amber-700 dark:text-amber-300">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]")}>
|
||||
{block.lines.length} log {block.lines.length === 1 ? "line" : "lines"}
|
||||
</span>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
{open && (
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-amber-700/80 dark:text-amber-300/80 pl-5">
|
||||
{block.lines.map((line, i) => (
|
||||
<span key={`${line.ts}-${i}`}>
|
||||
<span className="select-none text-amber-500/50 dark:text-amber-400/40">{i > 0 ? "\n" : ""}</span>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptStdoutRow({
|
||||
block,
|
||||
density,
|
||||
|
|
@ -1003,6 +1241,8 @@ export function RunTranscriptView({
|
|||
)}
|
||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
|
||||
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
|
||||
{block.type === "stdout" && (
|
||||
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
|
|
@ -24,6 +24,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
|||
},
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listMineByMe("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listTouchedByMe("company-1"),
|
||||
});
|
||||
|
|
@ -114,3 +117,129 @@ describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider run lifecycle toasts", () => {
|
||||
it("does not build start or success toasts for agent runs", () => {
|
||||
const queryClient = {
|
||||
getQueryData: () => [],
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.buildAgentStatusToast(
|
||||
{
|
||||
agentId: "agent-1",
|
||||
status: "running",
|
||||
},
|
||||
() => "CodexCoder",
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.buildRunStatusToast(
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
status: "succeeded",
|
||||
},
|
||||
() => "CodexCoder",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still builds failure toasts for agent errors and failed runs", () => {
|
||||
const queryClient = {
|
||||
getQueryData: () => [
|
||||
{
|
||||
id: "agent-1",
|
||||
title: "Software Engineer",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.buildAgentStatusToast(
|
||||
{
|
||||
agentId: "agent-1",
|
||||
status: "error",
|
||||
},
|
||||
() => "CodexCoder",
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
),
|
||||
).toMatchObject({
|
||||
title: "CodexCoder errored",
|
||||
body: "Software Engineer",
|
||||
tone: "error",
|
||||
});
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.buildRunStatusToast(
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
status: "failed",
|
||||
error: "boom",
|
||||
},
|
||||
() => "CodexCoder",
|
||||
),
|
||||
).toMatchObject({
|
||||
title: "CodexCoder run failed",
|
||||
body: "boom",
|
||||
tone: "error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider socket helpers", () => {
|
||||
it("waits for the selected company object to catch up before connecting", () => {
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", null)).toBeNull();
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-2")).toBeNull();
|
||||
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-1")).toBe("company-1");
|
||||
});
|
||||
|
||||
it("defers close until onopen for sockets that are still connecting", () => {
|
||||
const socket = {
|
||||
readyState: 0,
|
||||
onopen: (() => undefined) as (() => void) | null,
|
||||
onmessage: (() => undefined) as (() => void) | null,
|
||||
onerror: (() => undefined) as (() => void) | null,
|
||||
onclose: (() => undefined) as (() => void) | null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "provider_unmount");
|
||||
|
||||
expect(socket.close).not.toHaveBeenCalled();
|
||||
expect(socket.onmessage).toBeNull();
|
||||
expect(socket.onclose).toBeNull();
|
||||
expect(socket.onopen).toBeTypeOf("function");
|
||||
expect(socket.onerror).toBeTypeOf("function");
|
||||
|
||||
socket.onopen?.();
|
||||
|
||||
expect(socket.close).toHaveBeenCalledWith(1000, "provider_unmount");
|
||||
expect(socket.onopen).toBeNull();
|
||||
expect(socket.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("closes open sockets immediately without leaving handlers behind", () => {
|
||||
const socket = {
|
||||
readyState: 1,
|
||||
onopen: (() => undefined) as (() => void) | null,
|
||||
onmessage: (() => undefined) as (() => void) | null,
|
||||
onerror: (() => undefined) as (() => void) | null,
|
||||
onclose: (() => undefined) as (() => void) | null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "stale_connection");
|
||||
|
||||
expect(socket.close).toHaveBeenCalledWith(1000, "stale_connection");
|
||||
expect(socket.onopen).toBeNull();
|
||||
expect(socket.onmessage).toBeNull();
|
||||
expect(socket.onerror).toBeNull();
|
||||
expect(socket.onclose).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,17 @@ import { useLocation } from "../lib/router";
|
|||
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
||||
const TOAST_COOLDOWN_MAX = 3;
|
||||
const RECONNECT_SUPPRESS_MS = 2000;
|
||||
const SOCKET_CONNECTING = 0;
|
||||
const SOCKET_OPEN = 1;
|
||||
|
||||
type LiveUpdatesSocketLike = {
|
||||
readyState: number;
|
||||
onopen: ((this: WebSocket, ev: Event) => unknown) | null;
|
||||
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null;
|
||||
onerror: ((this: WebSocket, ev: Event) => unknown) | null;
|
||||
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
};
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
|
|
@ -244,8 +255,8 @@ function shouldSuppressAgentStatusToastForVisibleIssue(
|
|||
}
|
||||
|
||||
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
||||
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["error"]);
|
||||
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
|
||||
|
||||
function describeIssueUpdate(details: Record<string, unknown> | null): string | null {
|
||||
if (!details) return null;
|
||||
|
|
@ -374,7 +385,7 @@ function buildJoinRequestToast(
|
|||
title: `${label} wants to join`,
|
||||
body: "A new join request is waiting for approval.",
|
||||
tone: "info",
|
||||
action: { label: "View inbox", href: "/inbox/unread" },
|
||||
action: { label: "View inbox", href: "/inbox/mine" },
|
||||
dedupeKey: `join-request:${entityId}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -416,7 +427,7 @@ function buildRunStatusToast(
|
|||
const runId = readString(payload.runId);
|
||||
const agentId = readString(payload.agentId);
|
||||
const status = readString(payload.status);
|
||||
if (!runId || !agentId || !status || !TERMINAL_RUN_STATUSES.has(status)) return null;
|
||||
if (!runId || !agentId || !status || !RUN_TOAST_STATUSES.has(status)) return null;
|
||||
|
||||
const error = readString(payload.error);
|
||||
const triggerDetail = readString(payload.triggerDetail);
|
||||
|
|
@ -479,6 +490,7 @@ function invalidateActivityQueries(
|
|||
|
||||
if (entityType === "issue") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
|
||||
if (entityId) {
|
||||
|
|
@ -651,33 +663,90 @@ function handleLiveEvent(
|
|||
}
|
||||
}
|
||||
|
||||
function resolveLiveCompanyId(
|
||||
selectedCompanyId: string | null,
|
||||
selectedCompanyLiveId: string | null,
|
||||
): string | null {
|
||||
return selectedCompanyId && selectedCompanyId === selectedCompanyLiveId
|
||||
? selectedCompanyId
|
||||
: null;
|
||||
}
|
||||
|
||||
function resetSocketHandlers(target: LiveUpdatesSocketLike) {
|
||||
target.onopen = null;
|
||||
target.onmessage = null;
|
||||
target.onerror = null;
|
||||
target.onclose = null;
|
||||
}
|
||||
|
||||
function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string) {
|
||||
if (!target) return;
|
||||
|
||||
if (target.readyState === SOCKET_CONNECTING) {
|
||||
// Let the handshake complete and then close. Calling close() while the
|
||||
// socket is still CONNECTING is what triggers the noisy browser error.
|
||||
target.onopen = () => {
|
||||
resetSocketHandlers(target);
|
||||
target.close(1000, reason);
|
||||
};
|
||||
target.onmessage = null;
|
||||
target.onerror = () => undefined;
|
||||
target.onclose = null;
|
||||
return;
|
||||
}
|
||||
|
||||
resetSocketHandlers(target);
|
||||
|
||||
if (target.readyState === SOCKET_OPEN) {
|
||||
target.close(1000, reason);
|
||||
}
|
||||
}
|
||||
|
||||
export const __liveUpdatesTestUtils = {
|
||||
buildAgentStatusToast,
|
||||
buildRunStatusToast,
|
||||
closeSocketQuietly,
|
||||
invalidateActivityQueries,
|
||||
resolveLiveCompanyId,
|
||||
shouldSuppressActivityToastForVisibleIssue,
|
||||
shouldSuppressRunStatusToastForVisibleIssue,
|
||||
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||
};
|
||||
|
||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const location = useLocation();
|
||||
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
||||
const pathnameRef = useRef(location.pathname);
|
||||
const { data: session } = useQuery({
|
||||
const { data: session, status: sessionStatus } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out";
|
||||
const liveCompanyId = resolveLiveCompanyId(selectedCompanyId, selectedCompany?.id ?? null);
|
||||
const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null;
|
||||
const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({
|
||||
userId: currentUserId,
|
||||
agentId: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
pathnameRef.current = location.pathname;
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId) return;
|
||||
currentActorRef.current = {
|
||||
userId: currentUserId,
|
||||
agentId: null,
|
||||
};
|
||||
}, [currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConnectSocket || !liveCompanyId) return;
|
||||
|
||||
let closed = false;
|
||||
let reconnectAttempt = 0;
|
||||
|
|
@ -704,55 +773,63 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||
const connect = () => {
|
||||
if (closed) return;
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`;
|
||||
socket = new WebSocket(url);
|
||||
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`;
|
||||
const nextSocket = new WebSocket(url);
|
||||
socket = nextSocket;
|
||||
|
||||
socket.onopen = () => {
|
||||
nextSocket.onopen = () => {
|
||||
if (closed || socket !== nextSocket) {
|
||||
closeSocketQuietly(nextSocket, "stale_connection");
|
||||
return;
|
||||
}
|
||||
if (reconnectAttempt > 0) {
|
||||
gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS;
|
||||
}
|
||||
reconnectAttempt = 0;
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
nextSocket.onmessage = (message) => {
|
||||
const raw = typeof message.data === "string" ? message.data : "";
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as LiveEvent;
|
||||
handleLiveEvent(queryClient, selectedCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, {
|
||||
userId: currentUserId,
|
||||
agentId: null,
|
||||
handleLiveEvent(queryClient, liveCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, {
|
||||
userId: currentActorRef.current.userId,
|
||||
agentId: currentActorRef.current.agentId,
|
||||
});
|
||||
} catch {
|
||||
// Ignore non-JSON payloads.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
socket?.close();
|
||||
nextSocket.onerror = () => {
|
||||
// Wait for onclose to drive the reconnect. Self-closing here is what
|
||||
// produces the "closed before connection established" browser noise.
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
nextSocket.onclose = () => {
|
||||
if (socket !== nextSocket) return;
|
||||
socket = null;
|
||||
if (closed) return;
|
||||
scheduleReconnect();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
// Delay initial connect slightly so React StrictMode's double-invoke
|
||||
// cleanup fires before the WebSocket is created, avoiding the
|
||||
// "WebSocket closed before connection established" dev-mode error.
|
||||
const connectTimer = window.setTimeout(connect, 0);
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
window.clearTimeout(connectTimer);
|
||||
clearReconnect();
|
||||
if (socket) {
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
socket.close(1000, "provider_unmount");
|
||||
}
|
||||
const activeSocket = socket;
|
||||
socket = null;
|
||||
closeSocketQuietly(activeSocket, "provider_unmount");
|
||||
};
|
||||
}, [queryClient, selectedCompanyId, pushToast, currentUserId]);
|
||||
}, [queryClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import {
|
|||
getRecentTouchedIssues,
|
||||
loadDismissedInboxItems,
|
||||
saveDismissedInboxItems,
|
||||
getUnreadTouchedIssues,
|
||||
loadReadInboxItems,
|
||||
saveReadInboxItems,
|
||||
READ_ITEMS_KEY,
|
||||
} from "../lib/inbox";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||
|
|
@ -41,6 +43,39 @@ export function useDismissedInboxItems() {
|
|||
return { dismissed, dismiss };
|
||||
}
|
||||
|
||||
export function useReadInboxItems() {
|
||||
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== READ_ITEMS_KEY) return;
|
||||
setReadItems(loadReadInboxItems());
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const markRead = (id: string) => {
|
||||
setReadItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
saveReadInboxItems(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const markUnread = (id: string) => {
|
||||
setReadItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
saveReadInboxItems(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return { readItems, markRead, markUnread };
|
||||
}
|
||||
|
||||
export function useInboxBadge(companyId: string | null | undefined) {
|
||||
const { dismissed } = useDismissedInboxItems();
|
||||
|
||||
|
|
@ -72,20 +107,18 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: touchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
|
||||
const { data: mineIssuesRaw = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.listMineByMe(companyId!),
|
||||
queryFn: () =>
|
||||
issuesApi.list(companyId!, {
|
||||
touchedByUserId: "me",
|
||||
inboxArchivedByUserId: "me",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
}),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const unreadIssues = useMemo(
|
||||
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
|
||||
[touchedIssues],
|
||||
);
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
|
||||
const { data: heartbeatRuns = [] } = useQuery({
|
||||
queryKey: queryKeys.heartbeats(companyId!),
|
||||
|
|
@ -100,9 +133,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||
joinRequests,
|
||||
dashboard,
|
||||
heartbeatRuns,
|
||||
unreadIssues,
|
||||
mineIssues,
|
||||
dismissed,
|
||||
}),
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed],
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -343,6 +343,17 @@
|
|||
margin-top: 1.1em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) {
|
||||
color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dark .paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) {
|
||||
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content a.paperclip-mention-chip,
|
||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
||||
display: inline-flex;
|
||||
|
|
@ -661,12 +672,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
|||
|
||||
.paperclip-markdown a {
|
||||
color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.paperclip-markdown a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paperclip-markdown a.paperclip-mention-chip {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dark .paperclip-markdown a {
|
||||
|
|
|
|||
31
ui/src/lib/comment-submit-draft.test.ts
Normal file
31
ui/src/lib/comment-submit-draft.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { restoreSubmittedCommentDraft } from "./comment-submit-draft";
|
||||
|
||||
describe("restoreSubmittedCommentDraft", () => {
|
||||
it("restores the submitted body when the editor is still empty after a failed request", () => {
|
||||
expect(
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: "",
|
||||
submittedBody: "Retry me",
|
||||
}),
|
||||
).toBe("Retry me");
|
||||
});
|
||||
|
||||
it("treats whitespace-only input as empty when restoring a failed draft", () => {
|
||||
expect(
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: " ",
|
||||
submittedBody: "Retry me",
|
||||
}),
|
||||
).toBe("Retry me");
|
||||
});
|
||||
|
||||
it("preserves newer input when the user has already typed again", () => {
|
||||
expect(
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: "new draft",
|
||||
submittedBody: "Retry me",
|
||||
}),
|
||||
).toBe("new draft");
|
||||
});
|
||||
});
|
||||
6
ui/src/lib/comment-submit-draft.ts
Normal file
6
ui/src/lib/comment-submit-draft.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function restoreSubmittedCommentDraft(params: {
|
||||
currentBody: string;
|
||||
submittedBody: string;
|
||||
}) {
|
||||
return params.currentBody.trim() ? params.currentBody : params.submittedBody;
|
||||
}
|
||||
23
ui/src/lib/company-routes.test.ts
Normal file
23
ui/src/lib/company-routes.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCompanyPrefix,
|
||||
extractCompanyPrefixFromPath,
|
||||
isBoardPathWithoutPrefix,
|
||||
toCompanyRelativePath,
|
||||
} from "./company-routes";
|
||||
|
||||
describe("company routes", () => {
|
||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
||||
"/PAP/execution-workspaces/workspace-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
||||
"/execution-workspaces/workspace-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"org",
|
||||
"agents",
|
||||
"projects",
|
||||
"execution-workspaces",
|
||||
"issues",
|
||||
"routines",
|
||||
"goals",
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ import {
|
|||
computeInboxBadgeData,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
isMineInboxTab,
|
||||
loadLastInboxTab,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
resolveInboxSelectionIndex,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
} from "./inbox";
|
||||
|
|
@ -210,7 +213,7 @@ describe("inbox helpers", () => {
|
|||
makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
|
||||
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
||||
],
|
||||
unreadIssues: [makeIssue("1", true)],
|
||||
mineIssues: [makeIssue("1", true)],
|
||||
dismissed: new Set<string>(),
|
||||
});
|
||||
|
||||
|
|
@ -219,7 +222,7 @@ describe("inbox helpers", () => {
|
|||
approvals: 1,
|
||||
failedRuns: 2,
|
||||
joinRequests: 1,
|
||||
unreadTouchedIssues: 1,
|
||||
mineIssues: 1,
|
||||
alerts: 1,
|
||||
});
|
||||
});
|
||||
|
|
@ -230,7 +233,7 @@ describe("inbox helpers", () => {
|
|||
joinRequests: [],
|
||||
dashboard,
|
||||
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
||||
unreadIssues: [],
|
||||
mineIssues: [],
|
||||
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
|
||||
});
|
||||
|
||||
|
|
@ -239,7 +242,7 @@ describe("inbox helpers", () => {
|
|||
approvals: 0,
|
||||
failedRuns: 0,
|
||||
joinRequests: 0,
|
||||
unreadTouchedIssues: 0,
|
||||
mineIssues: 0,
|
||||
alerts: 0,
|
||||
});
|
||||
});
|
||||
|
|
@ -262,6 +265,11 @@ describe("inbox helpers", () => {
|
|||
),
|
||||
];
|
||||
|
||||
expect(getApprovalsForTab(approvals, "mine", "all").map((approval) => approval.id)).toEqual([
|
||||
"approval-revision",
|
||||
"approval-approved",
|
||||
"approval-pending",
|
||||
]);
|
||||
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
|
||||
"approval-revision",
|
||||
"approval-approved",
|
||||
|
|
@ -338,10 +346,21 @@ describe("inbox helpers", () => {
|
|||
});
|
||||
|
||||
it("can include sections on recent without forcing them to be unread", () => {
|
||||
expect(
|
||||
shouldShowInboxSection({
|
||||
tab: "mine",
|
||||
hasItems: true,
|
||||
showOnMine: true,
|
||||
showOnRecent: false,
|
||||
showOnUnread: false,
|
||||
showOnAll: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowInboxSection({
|
||||
tab: "recent",
|
||||
hasItems: true,
|
||||
showOnMine: false,
|
||||
showOnRecent: true,
|
||||
showOnUnread: false,
|
||||
showOnAll: false,
|
||||
|
|
@ -351,6 +370,7 @@ describe("inbox helpers", () => {
|
|||
shouldShowInboxSection({
|
||||
tab: "unread",
|
||||
hasItems: true,
|
||||
showOnMine: true,
|
||||
showOnRecent: true,
|
||||
showOnUnread: false,
|
||||
showOnAll: false,
|
||||
|
|
@ -371,16 +391,36 @@ describe("inbox helpers", () => {
|
|||
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("defaults the remembered inbox tab to recent and persists all", () => {
|
||||
it("defaults the remembered inbox tab to mine and persists all", () => {
|
||||
localStorage.clear();
|
||||
expect(loadLastInboxTab()).toBe("recent");
|
||||
expect(loadLastInboxTab()).toBe("mine");
|
||||
|
||||
saveLastInboxTab("all");
|
||||
expect(loadLastInboxTab()).toBe("all");
|
||||
});
|
||||
|
||||
it("maps legacy new-tab storage to recent", () => {
|
||||
it("maps legacy new-tab storage to mine", () => {
|
||||
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
||||
expect(loadLastInboxTab()).toBe("recent");
|
||||
expect(loadLastInboxTab()).toBe("mine");
|
||||
});
|
||||
|
||||
it("enables swipe archive only on the mine tab", () => {
|
||||
expect(isMineInboxTab("mine")).toBe(true);
|
||||
expect(isMineInboxTab("recent")).toBe(false);
|
||||
expect(isMineInboxTab("unread")).toBe(false);
|
||||
expect(isMineInboxTab("all")).toBe(false);
|
||||
});
|
||||
|
||||
it("anchors Mine selection to the first available inbox row", () => {
|
||||
expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1);
|
||||
expect(resolveInboxSelectionIndex(5, 3)).toBe(2);
|
||||
expect(resolveInboxSelectionIndex(1, 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it("selects the first row only after keyboard navigation starts", () => {
|
||||
expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0);
|
||||
expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0);
|
||||
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
|
||||
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ export const RECENT_ISSUES_LIMIT = 100;
|
|||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||
export type InboxTab = "recent" | "unread" | "all";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
export type InboxWorkItem =
|
||||
| {
|
||||
|
|
@ -40,7 +41,7 @@ export interface InboxBadgeData {
|
|||
approvals: number;
|
||||
failedRuns: number;
|
||||
joinRequests: number;
|
||||
unreadTouchedIssues: number;
|
||||
mineIssues: number;
|
||||
alerts: number;
|
||||
}
|
||||
|
||||
|
|
@ -61,14 +62,31 @@ export function saveDismissedInboxItems(ids: Set<string>) {
|
|||
}
|
||||
}
|
||||
|
||||
export function loadReadInboxItems(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(READ_ITEMS_KEY);
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveReadInboxItems(ids: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(READ_ITEMS_KEY, JSON.stringify([...ids]));
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadLastInboxTab(): InboxTab {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
||||
if (raw === "all" || raw === "unread" || raw === "recent") return raw;
|
||||
if (raw === "new") return "recent";
|
||||
return "recent";
|
||||
if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw;
|
||||
if (raw === "new") return "mine";
|
||||
return "mine";
|
||||
} catch {
|
||||
return "recent";
|
||||
return "mine";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +98,31 @@ export function saveLastInboxTab(tab: InboxTab) {
|
|||
}
|
||||
}
|
||||
|
||||
export function isMineInboxTab(tab: InboxTab): boolean {
|
||||
return tab === "mine";
|
||||
}
|
||||
|
||||
export function resolveInboxSelectionIndex(
|
||||
previousIndex: number,
|
||||
itemCount: number,
|
||||
): number {
|
||||
if (itemCount === 0) return -1;
|
||||
if (previousIndex < 0) return -1;
|
||||
return Math.min(previousIndex, itemCount - 1);
|
||||
}
|
||||
|
||||
export function getInboxKeyboardSelectionIndex(
|
||||
previousIndex: number,
|
||||
itemCount: number,
|
||||
direction: "next" | "previous",
|
||||
): number {
|
||||
if (itemCount === 0) return -1;
|
||||
if (previousIndex < 0) return 0;
|
||||
return direction === "next"
|
||||
? Math.min(previousIndex + 1, itemCount - 1)
|
||||
: Math.max(previousIndex - 1, 0);
|
||||
}
|
||||
|
||||
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||
const sorted = [...runs].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
|
|
@ -135,7 +178,7 @@ export function getApprovalsForTab(
|
|||
(a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt),
|
||||
);
|
||||
|
||||
if (tab === "recent") return sortedApprovals;
|
||||
if (tab === "mine" || tab === "recent") return sortedApprovals;
|
||||
if (tab === "unread") {
|
||||
return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status));
|
||||
}
|
||||
|
|
@ -203,17 +246,20 @@ export function getInboxWorkItems({
|
|||
export function shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems,
|
||||
showOnMine,
|
||||
showOnRecent,
|
||||
showOnUnread,
|
||||
showOnAll,
|
||||
}: {
|
||||
tab: InboxTab;
|
||||
hasItems: boolean;
|
||||
showOnMine: boolean;
|
||||
showOnRecent: boolean;
|
||||
showOnUnread: boolean;
|
||||
showOnAll: boolean;
|
||||
}): boolean {
|
||||
if (!hasItems) return false;
|
||||
if (tab === "mine") return showOnMine;
|
||||
if (tab === "recent") return showOnRecent;
|
||||
if (tab === "unread") return showOnUnread;
|
||||
return showOnAll;
|
||||
|
|
@ -224,23 +270,28 @@ export function computeInboxBadgeData({
|
|||
joinRequests,
|
||||
dashboard,
|
||||
heartbeatRuns,
|
||||
unreadIssues,
|
||||
mineIssues,
|
||||
dismissed,
|
||||
}: {
|
||||
approvals: Approval[];
|
||||
joinRequests: JoinRequest[];
|
||||
dashboard: DashboardSummary | undefined;
|
||||
heartbeatRuns: HeartbeatRun[];
|
||||
unreadIssues: Issue[];
|
||||
mineIssues: Issue[];
|
||||
dismissed: Set<string>;
|
||||
}): InboxBadgeData {
|
||||
const actionableApprovals = approvals.filter((approval) =>
|
||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
|
||||
const actionableApprovals = approvals.filter(
|
||||
(approval) =>
|
||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
||||
!dismissed.has(`approval:${approval.id}`),
|
||||
).length;
|
||||
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
||||
(run) => !dismissed.has(`run:${run.id}`),
|
||||
).length;
|
||||
const unreadTouchedIssues = unreadIssues.length;
|
||||
const visibleJoinRequests = joinRequests.filter(
|
||||
(jr) => !dismissed.has(`join:${jr.id}`),
|
||||
).length;
|
||||
const visibleMineIssues = mineIssues.length;
|
||||
const agentErrorCount = dashboard?.agents.error ?? 0;
|
||||
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
|
||||
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
|
||||
|
|
@ -255,11 +306,11 @@ export function computeInboxBadgeData({
|
|||
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
||||
|
||||
return {
|
||||
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
|
||||
inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues + alerts,
|
||||
approvals: actionableApprovals,
|
||||
failedRuns,
|
||||
joinRequests: joinRequests.length,
|
||||
unreadTouchedIssues,
|
||||
joinRequests: visibleJoinRequests,
|
||||
mineIssues: visibleMineIssues,
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
34
ui/src/lib/issueDetailBreadcrumb.test.ts
Normal file
34
ui/src/lib/issueDetailBreadcrumb.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
readIssueDetailBreadcrumb,
|
||||
} from "./issueDetailBreadcrumb";
|
||||
|
||||
describe("issueDetailBreadcrumb", () => {
|
||||
it("prefers the full breadcrumb from route state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/inbox/mine",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the source query param when route state is unavailable", () => {
|
||||
expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/inbox",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the source query param when building an issue detail path", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox");
|
||||
});
|
||||
|
||||
it("reuses the current source query param when state has been dropped", () => {
|
||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
type IssueDetailSource = "issues" | "inbox";
|
||||
|
||||
type IssueDetailBreadcrumb = {
|
||||
label: string;
|
||||
href: string;
|
||||
|
|
@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = {
|
|||
|
||||
type IssueDetailLocationState = {
|
||||
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
|
||||
issueDetailSource?: IssueDetailSource;
|
||||
};
|
||||
|
||||
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||
|
||||
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Partial<IssueDetailBreadcrumb>;
|
||||
return typeof candidate.label === "string" && typeof candidate.href === "string";
|
||||
}
|
||||
|
||||
export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState {
|
||||
return { issueDetailBreadcrumb: { label, href } };
|
||||
function isIssueDetailSource(value: unknown): value is IssueDetailSource {
|
||||
return value === "issues" || value === "inbox";
|
||||
}
|
||||
|
||||
export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null {
|
||||
function readIssueDetailSource(state: unknown): IssueDetailSource | null {
|
||||
if (typeof state !== "object" || state === null) return null;
|
||||
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
||||
return isIssueDetailBreadcrumb(candidate) ? candidate : null;
|
||||
const source = (state as IssueDetailLocationState).issueDetailSource;
|
||||
return isIssueDetailSource(source) ? source : null;
|
||||
}
|
||||
|
||||
function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null {
|
||||
if (!search) return null;
|
||||
const params = new URLSearchParams(search);
|
||||
const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM);
|
||||
return isIssueDetailSource(source) ? source : null;
|
||||
}
|
||||
|
||||
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
|
||||
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
||||
return { label: "Issues", href: "/issues" };
|
||||
}
|
||||
|
||||
export function createIssueDetailLocationState(
|
||||
label: string,
|
||||
href: string,
|
||||
source?: IssueDetailSource,
|
||||
): IssueDetailLocationState {
|
||||
return {
|
||||
issueDetailBreadcrumb: { label, href },
|
||||
issueDetailSource: source,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
|
||||
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
|
||||
if (!source) return `/issues/${issuePathId}`;
|
||||
const params = new URLSearchParams();
|
||||
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
|
||||
return `/issues/${issuePathId}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
|
||||
if (typeof state === "object" && state !== null) {
|
||||
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
||||
if (isIssueDetailBreadcrumb(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const source = readIssueDetailSourceFromSearch(search);
|
||||
return source ? breadcrumbForSource(source) : null;
|
||||
}
|
||||
|
|
|
|||
215
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
215
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyOptimisticIssueCommentUpdate,
|
||||
createOptimisticIssueComment,
|
||||
isQueuedIssueComment,
|
||||
mergeIssueComments,
|
||||
upsertIssueComment,
|
||||
} from "./optimistic-issue-comments";
|
||||
|
||||
describe("optimistic issue comments", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates a pending optimistic comment for the current user", () => {
|
||||
const comment = createOptimisticIssueComment({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
body: "Working on it",
|
||||
authorUserId: "board-1",
|
||||
});
|
||||
|
||||
expect(comment.id).toMatch(/^optimistic-/);
|
||||
expect(comment.clientId).toBe(comment.id);
|
||||
expect(comment.clientStatus).toBe("pending");
|
||||
expect(comment.authorUserId).toBe("board-1");
|
||||
expect(comment.authorAgentId).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back when crypto.randomUUID is unavailable", () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_746_000_000_000);
|
||||
const mathSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456789);
|
||||
|
||||
const comment = createOptimisticIssueComment({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
body: "Working on it",
|
||||
authorUserId: "board-1",
|
||||
});
|
||||
|
||||
expect(comment.id).toBe("optimistic-1746000000000-4fzzzxjy");
|
||||
expect(comment.clientId).toBe(comment.id);
|
||||
|
||||
nowSpy.mockRestore();
|
||||
mathSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("supports queued optimistic comments for active-run follow-ups", () => {
|
||||
const comment = createOptimisticIssueComment({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
body: "Queue this",
|
||||
authorUserId: "board-1",
|
||||
clientStatus: "queued",
|
||||
queueTargetRunId: "run-1",
|
||||
});
|
||||
|
||||
expect(comment.clientStatus).toBe("queued");
|
||||
expect(comment.queueTargetRunId).toBe("run-1");
|
||||
});
|
||||
|
||||
it("merges optimistic comments into the server thread in chronological order", () => {
|
||||
const merged = mergeIssueComments(
|
||||
[
|
||||
{
|
||||
id: "comment-2",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-1",
|
||||
body: "Second",
|
||||
createdAt: new Date("2026-03-28T14:00:02.000Z"),
|
||||
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "optimistic-1",
|
||||
clientId: "optimistic-1",
|
||||
clientStatus: "pending",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-1",
|
||||
body: "First",
|
||||
createdAt: new Date("2026-03-28T14:00:01.000Z"),
|
||||
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]);
|
||||
});
|
||||
|
||||
it("upserts confirmed comments without creating duplicates", () => {
|
||||
const next = upsertIssueComment(
|
||||
[
|
||||
{
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-1",
|
||||
body: "Original",
|
||||
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
{
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-1",
|
||||
body: "Updated",
|
||||
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-28T14:00:05.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0]?.body).toBe("Updated");
|
||||
});
|
||||
|
||||
it("applies optimistic reopen and reassignment updates to the issue cache", () => {
|
||||
const next = applyOptimisticIssueCommentUpdate(
|
||||
{
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Fix comment flow",
|
||||
description: null,
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-1",
|
||||
issueNumber: 1,
|
||||
identifier: "PAP-1",
|
||||
originKind: "manual",
|
||||
originId: null,
|
||||
originRunId: null,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
reopen: true,
|
||||
reassignment: {
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "board-2",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(next?.status).toBe("todo");
|
||||
expect(next?.assigneeAgentId).toBeNull();
|
||||
expect(next?.assigneeUserId).toBe("board-2");
|
||||
});
|
||||
|
||||
it("treats comments without a run id as queued when they arrive during an active run", () => {
|
||||
expect(
|
||||
isQueuedIssueComment({
|
||||
comment: {
|
||||
createdAt: new Date("2026-03-28T16:20:05.000Z"),
|
||||
},
|
||||
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
|
||||
runId: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mark comments with an associated run as queued", () => {
|
||||
expect(
|
||||
isQueuedIssueComment({
|
||||
comment: {
|
||||
createdAt: new Date("2026-03-28T16:20:05.000Z"),
|
||||
},
|
||||
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
|
||||
runId: "run-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark interrupt comments as queued", () => {
|
||||
expect(
|
||||
isQueuedIssueComment({
|
||||
comment: {
|
||||
createdAt: new Date("2026-03-28T16:20:05.000Z"),
|
||||
},
|
||||
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
|
||||
interruptedRunId: "run-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
123
ui/src/lib/optimistic-issue-comments.ts
Normal file
123
ui/src/lib/optimistic-issue-comments.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { Issue, IssueComment } from "@paperclipai/shared";
|
||||
|
||||
export interface IssueCommentReassignment {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
export interface OptimisticIssueComment extends IssueComment {
|
||||
clientId: string;
|
||||
clientStatus: "pending" | "queued";
|
||||
queueTargetRunId?: string | null;
|
||||
}
|
||||
|
||||
export type IssueTimelineComment = IssueComment | OptimisticIssueComment;
|
||||
|
||||
function toTimestamp(value: Date | string) {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
function createOptimisticCommentId() {
|
||||
const randomUuid = globalThis.crypto?.randomUUID?.();
|
||||
if (randomUuid) {
|
||||
return `optimistic-${randomUuid}`;
|
||||
}
|
||||
return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function sortIssueComments<T extends { createdAt: Date | string; id: string }>(comments: T[]) {
|
||||
return [...comments].sort((a, b) => {
|
||||
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||
if (createdAtDiff !== 0) return createdAtDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function createOptimisticIssueComment(params: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
authorUserId: string | null;
|
||||
clientStatus?: OptimisticIssueComment["clientStatus"];
|
||||
queueTargetRunId?: string | null;
|
||||
}): OptimisticIssueComment {
|
||||
const now = new Date();
|
||||
const clientId = createOptimisticCommentId();
|
||||
return {
|
||||
id: clientId,
|
||||
clientId,
|
||||
companyId: params.companyId,
|
||||
issueId: params.issueId,
|
||||
authorAgentId: null,
|
||||
authorUserId: params.authorUserId,
|
||||
body: params.body,
|
||||
clientStatus: params.clientStatus ?? "pending",
|
||||
queueTargetRunId: params.queueTargetRunId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function isQueuedIssueComment(params: {
|
||||
comment: Pick<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
|
||||
activeRunStartedAt?: Date | string | null;
|
||||
runId?: string | null;
|
||||
interruptedRunId?: string | null;
|
||||
}) {
|
||||
if (params.runId) return false;
|
||||
if (params.interruptedRunId) return false;
|
||||
if (params.comment.clientStatus === "queued") return true;
|
||||
if (!params.activeRunStartedAt) return false;
|
||||
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
|
||||
}
|
||||
|
||||
export function mergeIssueComments(
|
||||
comments: IssueComment[] | undefined,
|
||||
optimisticComments: OptimisticIssueComment[],
|
||||
): IssueTimelineComment[] {
|
||||
const merged = [...(comments ?? [])];
|
||||
const existingIds = new Set(merged.map((comment) => comment.id));
|
||||
for (const comment of optimisticComments) {
|
||||
if (!existingIds.has(comment.id)) {
|
||||
merged.push(comment);
|
||||
}
|
||||
}
|
||||
return sortIssueComments(merged);
|
||||
}
|
||||
|
||||
export function upsertIssueComment(
|
||||
comments: IssueComment[] | undefined,
|
||||
nextComment: IssueComment,
|
||||
): IssueComment[] {
|
||||
const current = comments ?? [];
|
||||
const existingIndex = current.findIndex((comment) => comment.id === nextComment.id);
|
||||
if (existingIndex === -1) {
|
||||
return sortIssueComments([...current, nextComment]);
|
||||
}
|
||||
|
||||
const updated = [...current];
|
||||
updated[existingIndex] = nextComment;
|
||||
return sortIssueComments(updated);
|
||||
}
|
||||
|
||||
export function applyOptimisticIssueCommentUpdate(
|
||||
issue: Issue | undefined,
|
||||
params: {
|
||||
reopen?: boolean;
|
||||
reassignment?: IssueCommentReassignment;
|
||||
},
|
||||
) {
|
||||
if (!issue) return issue;
|
||||
const nextIssue: Issue = { ...issue };
|
||||
|
||||
if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) {
|
||||
nextIssue.status = "todo";
|
||||
}
|
||||
|
||||
if (params.reassignment) {
|
||||
nextIssue.assigneeAgentId = params.reassignment.assigneeAgentId;
|
||||
nextIssue.assigneeUserId = params.reassignment.assigneeUserId;
|
||||
}
|
||||
|
||||
return nextIssue;
|
||||
}
|
||||
231
ui/src/lib/project-workspaces-tab.test.ts
Normal file
231
ui/src/lib/project-workspaces-tab.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab";
|
||||
|
||||
function createProjectWorkspace(overrides: Partial<ProjectWorkspace>): ProjectWorkspace {
|
||||
return {
|
||||
id: overrides.id ?? "workspace-default",
|
||||
companyId: overrides.companyId ?? "company-1",
|
||||
projectId: overrides.projectId ?? "project-1",
|
||||
name: overrides.name ?? "paperclip",
|
||||
sourceType: overrides.sourceType ?? "local_path",
|
||||
cwd: overrides.cwd ?? "/repo",
|
||||
repoUrl: overrides.repoUrl ?? null,
|
||||
repoRef: overrides.repoRef ?? null,
|
||||
defaultRef: overrides.defaultRef ?? null,
|
||||
visibility: overrides.visibility ?? "default",
|
||||
setupCommand: overrides.setupCommand ?? null,
|
||||
cleanupCommand: overrides.cleanupCommand ?? null,
|
||||
remoteProvider: overrides.remoteProvider ?? null,
|
||||
remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null,
|
||||
sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null,
|
||||
metadata: overrides.metadata ?? null,
|
||||
runtimeConfig: overrides.runtimeConfig ?? null,
|
||||
isPrimary: overrides.isPrimary ?? false,
|
||||
runtimeServices: overrides.runtimeServices ?? [],
|
||||
createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function createIssue(overrides: Partial<Issue>): Issue {
|
||||
return {
|
||||
id: overrides.id ?? "issue-1",
|
||||
companyId: overrides.companyId ?? "company-1",
|
||||
projectId: overrides.projectId ?? "project-1",
|
||||
projectWorkspaceId: overrides.projectWorkspaceId ?? null,
|
||||
goalId: overrides.goalId ?? null,
|
||||
parentId: overrides.parentId ?? null,
|
||||
title: overrides.title ?? "Issue",
|
||||
description: overrides.description ?? null,
|
||||
status: overrides.status ?? "todo",
|
||||
priority: overrides.priority ?? "medium",
|
||||
assigneeAgentId: overrides.assigneeAgentId ?? null,
|
||||
assigneeUserId: overrides.assigneeUserId ?? null,
|
||||
checkoutRunId: overrides.checkoutRunId ?? null,
|
||||
executionRunId: overrides.executionRunId ?? null,
|
||||
executionAgentNameKey: overrides.executionAgentNameKey ?? null,
|
||||
executionLockedAt: overrides.executionLockedAt ?? null,
|
||||
createdByAgentId: overrides.createdByAgentId ?? null,
|
||||
createdByUserId: overrides.createdByUserId ?? null,
|
||||
issueNumber: overrides.issueNumber ?? null,
|
||||
identifier: overrides.identifier ?? null,
|
||||
requestDepth: overrides.requestDepth ?? 0,
|
||||
billingCode: overrides.billingCode ?? null,
|
||||
assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null,
|
||||
executionWorkspaceId: overrides.executionWorkspaceId ?? null,
|
||||
executionWorkspacePreference: overrides.executionWorkspacePreference ?? null,
|
||||
executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null,
|
||||
startedAt: overrides.startedAt ?? null,
|
||||
completedAt: overrides.completedAt ?? null,
|
||||
cancelledAt: overrides.cancelledAt ?? null,
|
||||
hiddenAt: overrides.hiddenAt ?? null,
|
||||
createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"),
|
||||
} as Issue;
|
||||
}
|
||||
|
||||
function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace>): ExecutionWorkspace {
|
||||
return {
|
||||
id: overrides.id ?? "exec-1",
|
||||
companyId: overrides.companyId ?? "company-1",
|
||||
projectId: overrides.projectId ?? "project-1",
|
||||
projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-default",
|
||||
sourceIssueId: overrides.sourceIssueId ?? null,
|
||||
mode: overrides.mode ?? "isolated_workspace",
|
||||
strategyType: overrides.strategyType ?? "git_worktree",
|
||||
name: overrides.name ?? "PAP-893",
|
||||
status: overrides.status ?? "active",
|
||||
cwd: overrides.cwd ?? "/repo/.worktrees/PAP-893",
|
||||
repoUrl: overrides.repoUrl ?? null,
|
||||
baseRef: overrides.baseRef ?? "public-gh/master",
|
||||
branchName: overrides.branchName ?? "PAP-893-workspaces-tab",
|
||||
providerType: overrides.providerType ?? "git_worktree",
|
||||
providerRef: overrides.providerRef ?? null,
|
||||
derivedFromExecutionWorkspaceId: overrides.derivedFromExecutionWorkspaceId ?? null,
|
||||
lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"),
|
||||
openedAt: overrides.openedAt ?? new Date("2026-03-26T09:00:00Z"),
|
||||
closedAt: overrides.closedAt ?? null,
|
||||
cleanupEligibleAt: overrides.cleanupEligibleAt ?? null,
|
||||
cleanupReason: overrides.cleanupReason ?? null,
|
||||
config: overrides.config ?? null,
|
||||
metadata: overrides.metadata ?? null,
|
||||
runtimeServices: overrides.runtimeServices ?? [],
|
||||
createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"),
|
||||
updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildProjectWorkspaceSummaries", () => {
|
||||
const primaryWorkspace = createProjectWorkspace({
|
||||
id: "workspace-default",
|
||||
isPrimary: true,
|
||||
name: "paperclip",
|
||||
});
|
||||
const featureWorkspace = createProjectWorkspace({
|
||||
id: "workspace-feature",
|
||||
name: "feature-checkout",
|
||||
repoRef: "feature/workspaces",
|
||||
updatedAt: new Date("2026-03-25T09:00:00Z"),
|
||||
});
|
||||
const project = {
|
||||
workspaces: [primaryWorkspace, featureWorkspace],
|
||||
primaryWorkspace,
|
||||
} satisfies Pick<Project, "workspaces" | "primaryWorkspace">;
|
||||
|
||||
it("groups isolated execution workspace issues ahead of shared non-primary workspace issues", () => {
|
||||
const summaries = buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: [
|
||||
createIssue({
|
||||
id: "issue-primary",
|
||||
projectWorkspaceId: primaryWorkspace.id,
|
||||
updatedAt: new Date("2026-03-26T08:00:00Z"),
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-feature-older",
|
||||
projectWorkspaceId: featureWorkspace.id,
|
||||
identifier: "PAP-800",
|
||||
updatedAt: new Date("2026-03-25T10:00:00Z"),
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-feature-newer",
|
||||
projectWorkspaceId: featureWorkspace.id,
|
||||
identifier: "PAP-801",
|
||||
updatedAt: new Date("2026-03-25T11:00:00Z"),
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-exec",
|
||||
projectWorkspaceId: primaryWorkspace.id,
|
||||
executionWorkspaceId: "exec-1",
|
||||
identifier: "PAP-893",
|
||||
updatedAt: new Date("2026-03-26T11:00:00Z"),
|
||||
}),
|
||||
],
|
||||
executionWorkspaces: [
|
||||
createExecutionWorkspace({
|
||||
id: "exec-1",
|
||||
name: "PAP-893",
|
||||
branchName: "PAP-893-workspaces-tab",
|
||||
lastUsedAt: new Date("2026-03-26T10:30:00Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(summaries).toHaveLength(3);
|
||||
expect(summaries[0]).toMatchObject({
|
||||
key: "execution:exec-1",
|
||||
kind: "execution_workspace",
|
||||
workspaceName: "PAP-893",
|
||||
branchName: "PAP-893-workspaces-tab",
|
||||
executionWorkspaceId: "exec-1",
|
||||
});
|
||||
expect(summaries[0]?.issues.map((issue) => issue.id)).toEqual(["issue-exec"]);
|
||||
|
||||
expect(summaries[1]).toMatchObject({
|
||||
key: "project:workspace-feature",
|
||||
kind: "project_workspace",
|
||||
workspaceName: "feature-checkout",
|
||||
branchName: "feature/workspaces",
|
||||
projectWorkspaceId: "workspace-feature",
|
||||
});
|
||||
expect(summaries[1]?.issues.map((issue) => issue.id)).toEqual([
|
||||
"issue-feature-newer",
|
||||
"issue-feature-older",
|
||||
]);
|
||||
expect(summaries[2]?.key).toBe("project:workspace-default");
|
||||
});
|
||||
|
||||
it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => {
|
||||
const summaries = buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: [
|
||||
createIssue({
|
||||
id: "issue-exec-derived",
|
||||
projectWorkspaceId: featureWorkspace.id,
|
||||
executionWorkspaceId: "exec-2",
|
||||
updatedAt: new Date("2026-03-26T12:00:00Z"),
|
||||
}),
|
||||
],
|
||||
executionWorkspaces: [
|
||||
createExecutionWorkspace({
|
||||
id: "exec-2",
|
||||
projectWorkspaceId: featureWorkspace.id,
|
||||
name: "feature-branch run",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(summaries).toHaveLength(2);
|
||||
expect(summaries[0]?.key).toBe("execution:exec-2");
|
||||
expect(summaries[1]?.key).toBe("project:workspace-default");
|
||||
});
|
||||
|
||||
it("excludes issues that only use the default shared workspace", () => {
|
||||
const summaries = buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: [
|
||||
createIssue({
|
||||
id: "issue-default-shared",
|
||||
projectWorkspaceId: primaryWorkspace.id,
|
||||
executionWorkspaceId: "exec-shared-default",
|
||||
updatedAt: new Date("2026-03-26T12:00:00Z"),
|
||||
}),
|
||||
],
|
||||
executionWorkspaces: [
|
||||
createExecutionWorkspace({
|
||||
id: "exec-shared-default",
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
projectWorkspaceId: primaryWorkspace.id,
|
||||
branchName: null,
|
||||
baseRef: null,
|
||||
providerType: "local_fs",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0]?.key).toBe("project:workspace-default");
|
||||
});
|
||||
});
|
||||
172
ui/src/lib/project-workspaces-tab.ts
Normal file
172
ui/src/lib/project-workspaces-tab.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
|
||||
|
||||
type ProjectWorkspaceLike = Pick<Project, "workspaces" | "primaryWorkspace">;
|
||||
|
||||
export interface ProjectWorkspaceSummary {
|
||||
key: string;
|
||||
kind: "execution_workspace" | "project_workspace";
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
cwd: string | null;
|
||||
branchName: string | null;
|
||||
lastUpdatedAt: Date;
|
||||
projectWorkspaceId: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
executionWorkspaceStatus: ExecutionWorkspace["status"] | null;
|
||||
serviceCount: number;
|
||||
runningServiceCount: number;
|
||||
primaryServiceUrl: string | null;
|
||||
hasRuntimeConfig: boolean;
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function maxDate(...values: Array<Date | string | null | undefined>): Date {
|
||||
let latest = new Date(0);
|
||||
for (const value of values) {
|
||||
const date = toDate(value);
|
||||
if (date && date.getTime() > latest.getTime()) latest = date;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null {
|
||||
return project.primaryWorkspace?.id
|
||||
?? project.workspaces.find((workspace) => workspace.isPrimary)?.id
|
||||
?? project.workspaces[0]?.id
|
||||
?? null;
|
||||
}
|
||||
|
||||
function isDefaultSharedExecutionWorkspace(input: {
|
||||
executionWorkspace: ExecutionWorkspace;
|
||||
issue: Issue;
|
||||
primaryWorkspaceId: string | null;
|
||||
}) {
|
||||
const linkedProjectWorkspaceId =
|
||||
input.executionWorkspace.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null;
|
||||
return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId;
|
||||
}
|
||||
|
||||
export function buildProjectWorkspaceSummaries(input: {
|
||||
project: ProjectWorkspaceLike;
|
||||
issues: Issue[];
|
||||
executionWorkspaces: ExecutionWorkspace[];
|
||||
}): ProjectWorkspaceSummary[] {
|
||||
const primaryId = primaryWorkspaceId(input.project);
|
||||
const executionWorkspacesById = new Map(
|
||||
input.executionWorkspaces.map((workspace) => [workspace.id, workspace] as const),
|
||||
);
|
||||
const projectWorkspacesById = new Map(
|
||||
input.project.workspaces.map((workspace) => [workspace.id, workspace] as const),
|
||||
);
|
||||
const summaries = new Map<string, ProjectWorkspaceSummary>();
|
||||
|
||||
for (const issue of input.issues) {
|
||||
if (issue.executionWorkspaceId) {
|
||||
const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId);
|
||||
if (!executionWorkspace) continue;
|
||||
if (executionWorkspace.status === "archived") continue;
|
||||
if (isDefaultSharedExecutionWorkspace({
|
||||
executionWorkspace,
|
||||
issue,
|
||||
primaryWorkspaceId: primaryId,
|
||||
})) continue;
|
||||
|
||||
const existing = summaries.get(`execution:${executionWorkspace.id}`);
|
||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
summaries.set(`execution:${executionWorkspace.id}`, {
|
||||
key: `execution:${executionWorkspace.id}`,
|
||||
kind: "execution_workspace",
|
||||
workspaceId: executionWorkspace.id,
|
||||
workspaceName: executionWorkspace.name,
|
||||
cwd: executionWorkspace.cwd ?? null,
|
||||
branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null,
|
||||
lastUpdatedAt: maxDate(
|
||||
existing?.lastUpdatedAt,
|
||||
executionWorkspace.lastUsedAt,
|
||||
executionWorkspace.updatedAt,
|
||||
issue.updatedAt,
|
||||
),
|
||||
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: executionWorkspace.id,
|
||||
executionWorkspaceStatus: executionWorkspace.status,
|
||||
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(
|
||||
executionWorkspace.config?.workspaceRuntime
|
||||
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
|
||||
),
|
||||
issues: nextIssues,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!issue.projectWorkspaceId || issue.projectWorkspaceId === primaryId) continue;
|
||||
const projectWorkspace = projectWorkspacesById.get(issue.projectWorkspaceId);
|
||||
if (!projectWorkspace) continue;
|
||||
|
||||
const existing = summaries.get(`project:${projectWorkspace.id}`);
|
||||
const nextIssues = [...(existing?.issues ?? []), issue].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
summaries.set(`project:${projectWorkspace.id}`, {
|
||||
key: `project:${projectWorkspace.id}`,
|
||||
kind: "project_workspace",
|
||||
workspaceId: projectWorkspace.id,
|
||||
workspaceName: projectWorkspace.name,
|
||||
cwd: projectWorkspace.cwd ?? null,
|
||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||
lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt),
|
||||
projectWorkspaceId: projectWorkspace.id,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
issues: nextIssues,
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectWorkspace of input.project.workspaces) {
|
||||
const key = `project:${projectWorkspace.id}`;
|
||||
if (summaries.has(key)) continue;
|
||||
const shouldSurfaceWorkspace =
|
||||
projectWorkspace.isPrimary
|
||||
|| Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime)
|
||||
|| (projectWorkspace.runtimeServices?.length ?? 0) > 0;
|
||||
if (!shouldSurfaceWorkspace) continue;
|
||||
summaries.set(key, {
|
||||
key,
|
||||
kind: "project_workspace",
|
||||
workspaceId: projectWorkspace.id,
|
||||
workspaceName: projectWorkspace.name,
|
||||
cwd: projectWorkspace.cwd ?? null,
|
||||
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
|
||||
lastUpdatedAt: maxDate(projectWorkspace.updatedAt),
|
||||
projectWorkspaceId: projectWorkspace.id,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspaceStatus: null,
|
||||
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
|
||||
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
|
||||
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
|
||||
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
|
||||
issues: [],
|
||||
});
|
||||
}
|
||||
|
||||
return [...summaries.values()].sort((a, b) => {
|
||||
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
|
||||
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);
|
||||
});
|
||||
}
|
||||
|
|
@ -25,17 +25,22 @@ export const queryKeys = {
|
|||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||
adapterModels: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "adapter-models", adapterType] as const,
|
||||
detectModel: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "detect-model", adapterType] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
search: (companyId: string, q: string, projectId?: string) =>
|
||||
["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const,
|
||||
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
|
||||
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
||||
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
|
||||
listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const,
|
||||
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||
listByProject: (companyId: string, projectId: string) =>
|
||||
["issues", companyId, "project", projectId] as const,
|
||||
listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) =>
|
||||
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
|
|
@ -58,6 +63,8 @@ export const queryKeys = {
|
|||
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||
["execution-workspaces", companyId, filters ?? {}] as const,
|
||||
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
|
||||
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
|
||||
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
|
||||
},
|
||||
projects: {
|
||||
list: (companyId: string) => ["projects", companyId] as const,
|
||||
|
|
|
|||
|
|
@ -165,3 +165,11 @@ export function projectRouteRef(project: { id: string; urlKey?: string | null; n
|
|||
export function projectUrl(project: { id: string; urlKey?: string | null; name?: string | null }): string {
|
||||
return `/projects/${projectRouteRef(project)}`;
|
||||
}
|
||||
|
||||
/** Build a project workspace URL scoped under its project. */
|
||||
export function projectWorkspaceUrl(
|
||||
project: { id: string; urlKey?: string | null; name?: string | null },
|
||||
workspaceId: string,
|
||||
): string {
|
||||
return `${projectUrl(project)}/workspaces/${workspaceId}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
|||
const isLive = run.status === "running" || run.status === "queued";
|
||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const summary = run.resultJson
|
||||
const summaryRaw = run.resultJson
|
||||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||
: run.error ?? "";
|
||||
|
||||
// Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines
|
||||
const summary = useMemo(() => {
|
||||
if (!summaryRaw) return "";
|
||||
const lines = summaryRaw
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l));
|
||||
const excerpt: string[] = [];
|
||||
let chars = 0;
|
||||
for (const line of lines) {
|
||||
if (excerpt.length >= 3 || chars + line.length > 280) break;
|
||||
excerpt.push(line);
|
||||
chars += line.length;
|
||||
}
|
||||
return excerpt.join(" ");
|
||||
}, [summaryRaw]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
|
|
@ -2351,6 +2369,7 @@ function AgentSkillsTab({
|
|||
const queryClient = useQueryClient();
|
||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
||||
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
|
||||
const lastSavedSkillsRef = useRef<string[]>([]);
|
||||
const hasHydratedSkillSnapshotRef = useRef(false);
|
||||
const skipNextSkillAutosaveRef = useRef(true);
|
||||
|
|
@ -2680,12 +2699,19 @@ function AgentSkillsTab({
|
|||
|
||||
{unmanagedSkillRows.length > 0 && (
|
||||
<section className="border-y border-border">
|
||||
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 select-none"
|
||||
onClick={() => setUnmanagedOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
User-installed skills, not managed by Paperclip
|
||||
({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip
|
||||
</span>
|
||||
{unmanagedOpen ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</div>
|
||||
{unmanagedSkillRows.map(renderSkillRow)}
|
||||
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
|||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
hermes_local: "Hermes",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ export function CompanySettings() {
|
|||
)}
|
||||
|
||||
{/* Hiring */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-testid="company-settings-team-section">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Hiring
|
||||
</div>
|
||||
|
|
@ -387,12 +387,13 @@ export function CompanySettings() {
|
|||
hint="New agent hires stay pending until approved by board."
|
||||
checked={!!selectedCompany.requireBoardApprovalForNewAgents}
|
||||
onChange={(v) => settingsMutation.mutate(v)}
|
||||
toggleTestId="company-settings-team-approval-toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invites */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-testid="company-settings-invites-section">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Invites
|
||||
</div>
|
||||
|
|
@ -405,6 +406,7 @@ export function CompanySettings() {
|
|||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-testid="company-settings-invites-generate-button"
|
||||
size="sm"
|
||||
onClick={() => inviteMutation.mutate()}
|
||||
disabled={inviteMutation.isPending}
|
||||
|
|
@ -418,7 +420,10 @@ export function CompanySettings() {
|
|||
<p className="text-sm text-destructive">{inviteError}</p>
|
||||
)}
|
||||
{inviteSnippet && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-2">
|
||||
<div
|
||||
className="rounded-md border border-border bg-muted/30 p-2"
|
||||
data-testid="company-settings-invites-snippet"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
OpenClaw Invite Prompt
|
||||
|
|
@ -435,12 +440,14 @@ export function CompanySettings() {
|
|||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
<textarea
|
||||
data-testid="company-settings-invites-snippet-textarea"
|
||||
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
|
||||
value={inviteSnippet}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
data-testid="company-settings-invites-copy-button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,33 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ExecutionWorkspace, Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
|
||||
type WorkspaceFormState = {
|
||||
name: string;
|
||||
cwd: string;
|
||||
repoUrl: string;
|
||||
baseRef: string;
|
||||
branchName: string;
|
||||
providerRef: string;
|
||||
provisionCommand: string;
|
||||
teardownCommand: string;
|
||||
cleanupCommand: string;
|
||||
inheritRuntime: boolean;
|
||||
workspaceRuntime: string;
|
||||
};
|
||||
|
||||
function isSafeExternalUrl(value: string | null | undefined) {
|
||||
if (!value) return false;
|
||||
|
|
@ -14,69 +39,829 @@ function isSafeExternalUrl(value: string | null | undefined) {
|
|||
}
|
||||
}
|
||||
|
||||
function readText(value: string | null | undefined) {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||
if (!value || Object.keys(value).length === 0) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function normalizeText(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseWorkspaceRuntimeJson(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return { ok: true as const, value: null as Record<string, unknown> | null };
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: "Workspace runtime JSON must be a JSON object.",
|
||||
};
|
||||
}
|
||||
return { ok: true as const, value: parsed as Record<string, unknown> };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error instanceof Error ? error.message : "Invalid JSON.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formStateFromWorkspace(workspace: ExecutionWorkspace): WorkspaceFormState {
|
||||
return {
|
||||
name: workspace.name,
|
||||
cwd: readText(workspace.cwd),
|
||||
repoUrl: readText(workspace.repoUrl),
|
||||
baseRef: readText(workspace.baseRef),
|
||||
branchName: readText(workspace.branchName),
|
||||
providerRef: readText(workspace.providerRef),
|
||||
provisionCommand: readText(workspace.config?.provisionCommand),
|
||||
teardownCommand: readText(workspace.config?.teardownCommand),
|
||||
cleanupCommand: readText(workspace.config?.cleanupCommand),
|
||||
inheritRuntime: !workspace.config?.workspaceRuntime,
|
||||
workspaceRuntime: formatJson(workspace.config?.workspaceRuntime),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const configPatch: Record<string, unknown> = {};
|
||||
|
||||
const maybeAssign = (
|
||||
key: keyof Pick<WorkspaceFormState, "name" | "cwd" | "repoUrl" | "baseRef" | "branchName" | "providerRef">,
|
||||
) => {
|
||||
if (initialState[key] === nextState[key]) return;
|
||||
patch[key] = key === "name" ? (normalizeText(nextState[key]) ?? initialState.name) : normalizeText(nextState[key]);
|
||||
};
|
||||
|
||||
maybeAssign("name");
|
||||
maybeAssign("cwd");
|
||||
maybeAssign("repoUrl");
|
||||
maybeAssign("baseRef");
|
||||
maybeAssign("branchName");
|
||||
maybeAssign("providerRef");
|
||||
|
||||
const maybeAssignConfigText = (key: keyof Pick<WorkspaceFormState, "provisionCommand" | "teardownCommand" | "cleanupCommand">) => {
|
||||
if (initialState[key] === nextState[key]) return;
|
||||
configPatch[key] = normalizeText(nextState[key]);
|
||||
};
|
||||
|
||||
maybeAssignConfigText("provisionCommand");
|
||||
maybeAssignConfigText("teardownCommand");
|
||||
maybeAssignConfigText("cleanupCommand");
|
||||
|
||||
if (initialState.inheritRuntime !== nextState.inheritRuntime || initialState.workspaceRuntime !== nextState.workspaceRuntime) {
|
||||
const parsed = parseWorkspaceRuntimeJson(nextState.workspaceRuntime);
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
configPatch.workspaceRuntime = nextState.inheritRuntime ? null : parsed.value;
|
||||
}
|
||||
|
||||
if (Object.keys(configPatch).length > 0) {
|
||||
patch.config = configPatch;
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function validateForm(form: WorkspaceFormState) {
|
||||
const repoUrl = normalizeText(form.repoUrl);
|
||||
if (repoUrl) {
|
||||
try {
|
||||
new URL(repoUrl);
|
||||
} catch {
|
||||
return "Repo URL must be a valid URL.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!form.inheritRuntime) {
|
||||
const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime);
|
||||
if (!runtimeJson.ok) {
|
||||
return runtimeJson.error;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
|
||||
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1.5">
|
||||
<div className="w-28 shrink-0 text-xs text-muted-foreground">{label}</div>
|
||||
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
|
||||
<div className="shrink-0 text-xs text-muted-foreground sm:w-32">{label}</div>
|
||||
<div className="min-w-0 flex-1 text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonoValue({ value, copy }: { value: string; copy?: boolean }) {
|
||||
return (
|
||||
<div className="inline-flex max-w-full items-start gap-2">
|
||||
<span className="break-all font-mono text-xs">{value}</span>
|
||||
{copy ? (
|
||||
<CopyText text={value} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLink({
|
||||
project,
|
||||
workspace,
|
||||
}: {
|
||||
project: Project;
|
||||
workspace: ProjectWorkspace;
|
||||
}) {
|
||||
return <Link to={projectWorkspaceUrl(project, workspace.id)} className="hover:underline">{workspace.name}</Link>;
|
||||
}
|
||||
|
||||
export function ExecutionWorkspaceDetail() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const [form, setForm] = useState<WorkspaceFormState | null>(null);
|
||||
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
|
||||
const { data: workspace, isLoading, error } = useQuery({
|
||||
const workspaceQuery = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
|
||||
queryFn: () => executionWorkspacesApi.get(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
const workspace = workspaceQuery.data ?? null;
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (error) return <p className="text-sm text-destructive">{error instanceof Error ? error.message : "Failed to load workspace"}</p>;
|
||||
if (!workspace) return null;
|
||||
const projectQuery = useQuery({
|
||||
queryKey: workspace ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"],
|
||||
queryFn: () => projectsApi.get(workspace!.projectId, workspace!.companyId),
|
||||
enabled: Boolean(workspace?.projectId),
|
||||
});
|
||||
const project = projectQuery.data ?? null;
|
||||
|
||||
const sourceIssueQuery = useQuery({
|
||||
queryKey: workspace?.sourceIssueId ? queryKeys.issues.detail(workspace.sourceIssueId) : ["issues", "detail", "__none__"],
|
||||
queryFn: () => issuesApi.get(workspace!.sourceIssueId!),
|
||||
enabled: Boolean(workspace?.sourceIssueId),
|
||||
});
|
||||
const sourceIssue = sourceIssueQuery.data ?? null;
|
||||
|
||||
const derivedWorkspaceQuery = useQuery({
|
||||
queryKey: workspace?.derivedFromExecutionWorkspaceId
|
||||
? queryKeys.executionWorkspaces.detail(workspace.derivedFromExecutionWorkspaceId)
|
||||
: ["execution-workspaces", "detail", "__none__"],
|
||||
queryFn: () => executionWorkspacesApi.get(workspace!.derivedFromExecutionWorkspaceId!),
|
||||
enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId),
|
||||
});
|
||||
const derivedWorkspace = derivedWorkspaceQuery.data ?? null;
|
||||
const linkedIssuesQuery = useQuery({
|
||||
queryKey: workspace
|
||||
? queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id)
|
||||
: ["issues", "__execution-workspace__", "__none__"],
|
||||
queryFn: () => issuesApi.list(workspace!.companyId, { executionWorkspaceId: workspace!.id }),
|
||||
enabled: Boolean(workspace?.companyId),
|
||||
});
|
||||
const linkedIssues = linkedIssuesQuery.data ?? [];
|
||||
|
||||
const linkedProjectWorkspace = useMemo(
|
||||
() => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null,
|
||||
[project, workspace?.projectWorkspaceId],
|
||||
);
|
||||
const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null;
|
||||
const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig;
|
||||
const runtimeConfigSource =
|
||||
workspace?.config?.workspaceRuntime
|
||||
? "execution_workspace"
|
||||
: inheritedRuntimeConfig
|
||||
? "project_workspace"
|
||||
: "none";
|
||||
|
||||
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
|
||||
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
|
||||
const projectRef = project ? projectRouteRef(project) : workspace?.projectId ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace?.companyId || workspace.companyId === selectedCompanyId) return;
|
||||
setSelectedCompanyId(workspace.companyId, { source: "route_sync" });
|
||||
}, [workspace?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
setForm(formStateFromWorkspace(workspace));
|
||||
setErrorMessage(null);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
const crumbs = [
|
||||
{ label: "Projects", href: "/projects" },
|
||||
...(project ? [{ label: project.name, href: `/projects/${projectRef}` }] : []),
|
||||
...(project ? [{ label: "Workspaces", href: `/projects/${projectRef}/workspaces` }] : []),
|
||||
{ label: workspace.name },
|
||||
];
|
||||
setBreadcrumbs(crumbs);
|
||||
}, [setBreadcrumbs, workspace, project, projectRef]);
|
||||
|
||||
const updateWorkspace = useMutation({
|
||||
mutationFn: (patch: Record<string, unknown>) => executionWorkspacesApi.update(workspace!.id, patch),
|
||||
onSuccess: (nextWorkspace) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) });
|
||||
if (project) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
|
||||
}
|
||||
if (sourceIssue) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) });
|
||||
}
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to save execution workspace.");
|
||||
},
|
||||
});
|
||||
const workspaceOperationsQuery = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspaceId!),
|
||||
queryFn: () => executionWorkspacesApi.listWorkspaceOperations(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
});
|
||||
const controlRuntimeServices = useMutation({
|
||||
mutationFn: (action: "start" | "stop" | "restart") =>
|
||||
executionWorkspacesApi.controlRuntimeServices(workspace!.id, action),
|
||||
onSuccess: (result, action) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionMessage(
|
||||
action === "stop"
|
||||
? "Runtime services stopped."
|
||||
: action === "restart"
|
||||
? "Runtime services restarted."
|
||||
: "Runtime services started.",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
setRuntimeActionMessage(null);
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services.");
|
||||
},
|
||||
});
|
||||
|
||||
if (workspaceQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace…</p>;
|
||||
if (workspaceQuery.error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
{workspaceQuery.error instanceof Error ? workspaceQuery.error.message : "Failed to load workspace"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!workspace || !form || !initialState) return null;
|
||||
|
||||
const saveChanges = () => {
|
||||
const validationError = validateForm(form);
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
let patch: Record<string, unknown>;
|
||||
try {
|
||||
patch = buildWorkspacePatch(initialState, form);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to build workspace update.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
updateWorkspace.mutate(patch);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Execution workspace</div>
|
||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{workspace.status} · {workspace.mode} · {workspace.providerType}
|
||||
<>
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back to all workspaces
|
||||
</Link>
|
||||
</Button>
|
||||
<StatusPill>{workspace.mode}</StatusPill>
|
||||
<StatusPill>{workspace.providerType}</StatusPill>
|
||||
<StatusPill className={workspace.status === "active" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined}>
|
||||
{workspace.status}
|
||||
</StatusPill>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Execution workspace
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay
|
||||
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
|
||||
and runtime-service behavior in sync with the actual workspace being reused.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setCloseDialogOpen(true)}
|
||||
disabled={workspace.status === "archived"}
|
||||
>
|
||||
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-5" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Workspace name">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||
placeholder="Execution workspace name"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Branch name" hint="Useful for isolated worktrees">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.branchName}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
|
||||
placeholder="PAP-946-workspace"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Working directory">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cwd}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Provider path / ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.providerRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
|
||||
placeholder="/path/to/worktree or provider ref"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Repo URL">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.repoUrl}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Base ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.baseRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.provisionCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/provision-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.teardownCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
|
||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Runtime config source
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{runtimeConfigSource === "execution_workspace"
|
||||
? "This execution workspace currently overrides the project workspace runtime config."
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "This execution workspace is inheriting the project workspace runtime config."
|
||||
: "No runtime config is currently defined on this execution workspace or its project workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
size="sm"
|
||||
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
|
||||
onClick={() =>
|
||||
setForm((current) => current ? {
|
||||
...current,
|
||||
inheritRuntime: true,
|
||||
workspaceRuntime: "",
|
||||
} : current)
|
||||
}
|
||||
>
|
||||
Reset to inherit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Runtime services JSON" hint="Concrete workspace runtime settings for this execution workspace. Leave this inheriting unless you need a one-off override. If you are missing the right commands, ask your CEO to set them up for you.">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
id="inherit-runtime-config"
|
||||
type="checkbox"
|
||||
checked={form.inheritRuntime}
|
||||
onChange={(event) =>
|
||||
setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current)
|
||||
}
|
||||
/>
|
||||
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-48 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={form.workspaceRuntime}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
|
||||
disabled={form.inheritRuntime}
|
||||
placeholder={'{\n "services": [\n {\n "name": "web",\n "command": "pnpm dev",\n "port": 3100\n }\n ]\n}'}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
||||
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!isDirty || updateWorkspace.isPending}
|
||||
onClick={() => {
|
||||
setForm(initialState);
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionMessage(null);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||||
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
|
||||
<h2 className="text-lg font-semibold">Workspace context</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Project">
|
||||
{project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
|
||||
</DetailRow>
|
||||
<DetailRow label="Project workspace">
|
||||
{project && linkedProjectWorkspace ? (
|
||||
<WorkspaceLink project={project} workspace={linkedProjectWorkspace} />
|
||||
) : workspace.projectWorkspaceId ? (
|
||||
<MonoValue value={workspace.projectWorkspaceId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source issue">
|
||||
{sourceIssue ? (
|
||||
<Link to={issueUrl(sourceIssue)} className="hover:underline">
|
||||
{sourceIssue.identifier ?? sourceIssue.id} · {sourceIssue.title}
|
||||
</Link>
|
||||
) : workspace.sourceIssueId ? (
|
||||
<MonoValue value={workspace.sourceIssueId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Derived from">
|
||||
{derivedWorkspace ? (
|
||||
<Link to={`/execution-workspaces/${derivedWorkspace.id}`} className="hover:underline">
|
||||
{derivedWorkspace.name}
|
||||
</Link>
|
||||
) : workspace.derivedFromExecutionWorkspaceId ? (
|
||||
<MonoValue value={workspace.derivedFromExecutionWorkspaceId} />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Workspace ID">
|
||||
<MonoValue value={workspace.id} />
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
|
||||
<h2 className="text-lg font-semibold">Concrete location</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Working dir">
|
||||
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Provider ref">
|
||||
{workspace.providerRef ? <MonoValue value={workspace.providerRef} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Repo URL">
|
||||
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
|
||||
<div className="inline-flex max-w-full items-start gap-2">
|
||||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex min-w-0 items-center gap-1 break-all hover:underline">
|
||||
{workspace.repoUrl}
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
</a>
|
||||
<CopyText text={workspace.repoUrl} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
</div>
|
||||
) : workspace.repoUrl ? (
|
||||
<MonoValue value={workspace.repoUrl} copy />
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Base ref">
|
||||
{workspace.baseRef ? <MonoValue value={workspace.baseRef} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">
|
||||
{workspace.branchName ? <MonoValue value={workspace.branchName} copy /> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Opened">{formatDateTime(workspace.openedAt)}</DetailRow>
|
||||
<DetailRow label="Last used">{formatDateTime(workspace.lastUsedAt)}</DetailRow>
|
||||
<DetailRow label="Cleanup">
|
||||
{workspace.cleanupEligibleAt
|
||||
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
|
||||
: "Not scheduled"}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
||||
<h2 className="text-lg font-semibold">Attached services</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Source: {runtimeConfigSource === "execution_workspace"
|
||||
? "execution workspace override"
|
||||
: runtimeConfigSource === "project_workspace"
|
||||
? "project workspace default"
|
||||
: "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("start")}
|
||||
>
|
||||
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("restart")}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspace.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{service.serviceName}</div>
|
||||
<div className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{service.url ? (
|
||||
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{service.url}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
{service.port ? <div>Port {service.port}</div> : null}
|
||||
{service.command ? <MonoValue value={service.command} copy /> : null}
|
||||
{service.cwd ? <MonoValue value={service.cwd} copy /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill className="self-start">{service.healthStatus}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{effectiveRuntimeConfig
|
||||
? "No runtime services are currently running for this execution workspace."
|
||||
: "No runtime config is defined for this execution workspace yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
|
||||
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspaceOperationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading workspace operations…</p>
|
||||
) : workspaceOperationsQuery.error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{workspaceOperationsQuery.error instanceof Error
|
||||
? workspaceOperationsQuery.error.message
|
||||
: "Failed to load workspace operations."}
|
||||
</p>
|
||||
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspaceOperationsQuery.data.slice(0, 6).map((operation) => (
|
||||
<div key={operation.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDateTime(operation.startedAt)}
|
||||
{operation.finishedAt ? ` → ${formatDateTime(operation.finishedAt)}` : ""}
|
||||
</div>
|
||||
{operation.stderrExcerpt ? (
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-destructive">{operation.stderrExcerpt}</div>
|
||||
) : operation.stdoutExcerpt ? (
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-muted-foreground">{operation.stdoutExcerpt}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusPill className="self-start">{operation.status}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
|
||||
<h2 className="text-lg font-semibold">Issues using this workspace</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Any issue attached to this execution workspace appears here so you can review the full session context before reusing or closing it.
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill>{linkedIssues.length} linked</StatusPill>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{linkedIssuesQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading linked issues…</p>
|
||||
) : linkedIssuesQuery.error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{linkedIssuesQuery.error instanceof Error
|
||||
? linkedIssuesQuery.error.message
|
||||
: "Failed to load linked issues."}
|
||||
</p>
|
||||
) : linkedIssues.length > 0 ? (
|
||||
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
|
||||
{linkedIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={issueUrl(issue)}
|
||||
className="min-w-72 rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-sm font-medium">{issue.title}</div>
|
||||
</div>
|
||||
<StatusPill className="shrink-0">{issue.status}</StatusPill>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-[0.16em]">{issue.priority}</span>
|
||||
<span>{formatDateTime(issue.updatedAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No issues are currently linked to this execution workspace.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<DetailRow label="Project">
|
||||
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source issue">
|
||||
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
|
||||
<DetailRow label="Base ref">{workspace.baseRef ?? "None"}</DetailRow>
|
||||
<DetailRow label="Working dir">
|
||||
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Provider ref">
|
||||
<span className="break-all font-mono text-xs">{workspace.providerRef ?? "None"}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Repo URL">
|
||||
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
|
||||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{workspace.repoUrl}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : workspace.repoUrl ? (
|
||||
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
|
||||
) : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Opened">{new Date(workspace.openedAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Last used">{new Date(workspace.lastUsedAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Cleanup">
|
||||
{workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"}
|
||||
</DetailRow>
|
||||
</div>
|
||||
</div>
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId={workspace.id}
|
||||
workspaceName={workspace.name}
|
||||
currentStatus={workspace.status}
|
||||
open={closeDialogOpen}
|
||||
onOpenChange={setCloseDialogOpen}
|
||||
onClosed={(nextWorkspace) => {
|
||||
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) });
|
||||
if (project) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(project.companyId, { projectId: project.id }) });
|
||||
}
|
||||
if (sourceIssue) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
181
ui/src/pages/Inbox.test.tsx
Normal file
181
ui/src/pages/Inbox.test.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
||||
<a className={className} {...props}>{children}</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
|
||||
useNavigate: () => () => {},
|
||||
}));
|
||||
|
||||
// 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",
|
||||
identifier: "PAP-904",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Inbox item",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 904,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
myLastTouchAt: null,
|
||||
lastExternalCommentAt: null,
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FailedRunInboxRow", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("suppresses accent hover styling when selected", () => {
|
||||
const root = createRoot(container);
|
||||
const run = {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: null,
|
||||
status: "failed",
|
||||
error: "boom",
|
||||
wakeupRequestId: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
contextSnapshot: null,
|
||||
startedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
} as const;
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FailedRunInboxRow
|
||||
run={run}
|
||||
issueById={new Map()}
|
||||
agentName="Agent"
|
||||
issueLinkState={null}
|
||||
onDismiss={() => {}}
|
||||
onRetry={() => {}}
|
||||
isRetrying={false}
|
||||
selected
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const link = container.querySelector("a");
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.className).toContain("hover:bg-transparent");
|
||||
expect(link?.className).not.toContain("hover:bg-accent/50");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxIssueMetaLeading", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("neutralizes selected status and live accents", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
|
||||
});
|
||||
|
||||
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
|
||||
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]');
|
||||
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
|
||||
(node) => node.textContent === "Live" && node.className.includes("text-"),
|
||||
);
|
||||
const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]');
|
||||
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
|
||||
|
||||
expect(statusIcon).not.toBeNull();
|
||||
expect(statusIcon?.className).toContain("!border-muted-foreground");
|
||||
expect(statusIcon?.className).toContain("!text-muted-foreground");
|
||||
expect(liveBadge).not.toBeNull();
|
||||
expect(liveBadge?.className).toContain("bg-muted");
|
||||
expect(liveBadgeLabel).not.toBeNull();
|
||||
expect(liveBadgeLabel?.className).toContain("text-muted-foreground");
|
||||
expect(liveBadgeLabel?.className).not.toContain("text-blue-600");
|
||||
expect(liveDot).not.toBeNull();
|
||||
expect(pulseRing).toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -20,11 +20,12 @@ const adapterLabels: Record<string, string> = {
|
|||
pi_local: "Pi (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
function dateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
|
|
@ -68,6 +69,7 @@ export function InviteLandingPage() {
|
|||
});
|
||||
|
||||
const invite = inviteQuery.data;
|
||||
const companyName = invite?.companyName?.trim() || null;
|
||||
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
|
||||
const availableJoinTypes = useMemo(() => {
|
||||
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
|
||||
|
|
@ -226,9 +228,18 @@ export function InviteLandingPage() {
|
|||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"}
|
||||
{invite.inviteType === "bootstrap_ceo"
|
||||
? "Bootstrap your Paperclip instance"
|
||||
: companyName
|
||||
? `Join ${companyName}`
|
||||
: "Join this Paperclip company"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Invite expires {dateTime(invite.expiresAt)}.</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{invite.inviteType !== "bootstrap_ceo" && companyName
|
||||
? `You were invited to join ${companyName}. `
|
||||
: null}
|
||||
Invite expires {dateTime(invite.expiresAt)}.
|
||||
</p>
|
||||
|
||||
{invite.inviteType !== "bootstrap_ceo" && (
|
||||
<div className="mt-5 flex gap-2">
|
||||
|
|
|
|||
|
|
@ -14,13 +14,23 @@ import { useToast } from "../context/ToastContext";
|
|||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
applyOptimisticIssueCommentUpdate,
|
||||
createOptimisticIssueComment,
|
||||
isQueuedIssueComment,
|
||||
mergeIssueComments,
|
||||
upsertIssueComment,
|
||||
type IssueCommentReassignment,
|
||||
type OptimisticIssueComment,
|
||||
} from "../lib/optimistic-issue-comments";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
|
|
@ -54,11 +64,15 @@ import {
|
|||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import type { Agent, IssueAttachment } from "@paperclipai/shared";
|
||||
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
|
||||
|
||||
type CommentReassignment = {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
type CommentReassignment = IssueCommentReassignment;
|
||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
runId?: string | null;
|
||||
runAgentId?: string | null;
|
||||
interruptedRunId?: string | null;
|
||||
queueState?: "queued";
|
||||
queueTargetRunId?: string | null;
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
|
|
@ -212,6 +226,7 @@ export function IssueDetail() {
|
|||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
||||
|
|
@ -268,9 +283,17 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const runningIssueRun = useMemo(
|
||||
() => (
|
||||
activeRun?.status === "running"
|
||||
? activeRun
|
||||
: (liveRuns ?? []).find((run) => run.status === "running") ?? null
|
||||
),
|
||||
[activeRun, liveRuns],
|
||||
);
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
|
||||
[location.state],
|
||||
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||
[location.state, location.search],
|
||||
);
|
||||
|
||||
// Filter out runs already shown by the live widget to avoid duplication
|
||||
|
|
@ -385,12 +408,23 @@ export function IssueDetail() {
|
|||
);
|
||||
|
||||
const suggestedAssigneeValue = useMemo(
|
||||
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
|
||||
[issue, comments, currentUserId],
|
||||
() =>
|
||||
suggestedCommentAssigneeValue(
|
||||
issue ?? {},
|
||||
mergeIssueComments(comments ?? [], optimisticComments),
|
||||
currentUserId,
|
||||
),
|
||||
[issue, comments, optimisticComments, currentUserId],
|
||||
);
|
||||
|
||||
const commentsWithRunMeta = useMemo(() => {
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
||||
const threadComments = useMemo(
|
||||
() => mergeIssueComments(comments ?? [], optimisticComments),
|
||||
[comments, optimisticComments],
|
||||
);
|
||||
|
||||
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
|
||||
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
|
||||
const agentIdByRunId = new Map<string, string>();
|
||||
for (const run of linkedRuns ?? []) {
|
||||
agentIdByRunId.set(run.runId, run.agentId);
|
||||
|
|
@ -400,16 +434,44 @@ export function IssueDetail() {
|
|||
const details = evt.details ?? {};
|
||||
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
||||
if (!commentId || runMetaByCommentId.has(commentId)) continue;
|
||||
const interruptedRunId =
|
||||
typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null;
|
||||
runMetaByCommentId.set(commentId, {
|
||||
runId: evt.runId,
|
||||
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
||||
interruptedRunId,
|
||||
});
|
||||
}
|
||||
return (comments ?? []).map((comment) => {
|
||||
return threadComments.map((comment) => {
|
||||
const meta = runMetaByCommentId.get(comment.id);
|
||||
return meta ? { ...comment, ...meta } : comment;
|
||||
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
|
||||
if (
|
||||
isQueuedIssueComment({
|
||||
comment: nextComment,
|
||||
activeRunStartedAt,
|
||||
runId: meta?.runId ?? nextComment.runId ?? null,
|
||||
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
...nextComment,
|
||||
queueState: "queued" as const,
|
||||
queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
|
||||
};
|
||||
}
|
||||
return nextComment;
|
||||
});
|
||||
}, [activity, comments, linkedRuns]);
|
||||
}, [activity, threadComments, linkedRuns, runningIssueRun]);
|
||||
|
||||
const queuedComments = useMemo(
|
||||
() => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"),
|
||||
[commentsWithRunMeta],
|
||||
);
|
||||
|
||||
const timelineComments = useMemo(
|
||||
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
|
||||
[commentsWithRunMeta],
|
||||
);
|
||||
|
||||
const issueCostSummary = useMemo(() => {
|
||||
let input = 0;
|
||||
|
|
@ -461,6 +523,7 @@ export function IssueDetail() {
|
|||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
|
|
@ -471,6 +534,7 @@ export function IssueDetail() {
|
|||
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||
onSuccess: () => {
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
|
|
@ -486,9 +550,67 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
const addComment = useMutation({
|
||||
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen),
|
||||
onSuccess: () => {
|
||||
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen, interrupt),
|
||||
onMutate: async ({ body, reopen, interrupt }) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt && runningIssueRun;
|
||||
const optimisticComment = issue
|
||||
? createOptimisticIssueComment({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
body,
|
||||
authorUserId: currentUserId,
|
||||
clientStatus: queuedComment ? "queued" : "pending",
|
||||
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (optimisticComment) {
|
||||
setOptimisticComments((current) => [...current, optimisticComment]);
|
||||
}
|
||||
if (previousIssue) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
applyOptimisticIssueCommentUpdate(previousIssue, { reopen }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
onSuccess: (comment, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
queryClient.setQueryData<IssueComment[]>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => upsertIssueComment(current, comment),
|
||||
);
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
if (context?.previousIssue) {
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
||||
}
|
||||
pushToast({
|
||||
title: "Comment failed",
|
||||
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
},
|
||||
|
|
@ -498,10 +620,12 @@ export function IssueDetail() {
|
|||
mutationFn: ({
|
||||
body,
|
||||
reopen,
|
||||
interrupt,
|
||||
reassignment,
|
||||
}: {
|
||||
body: string;
|
||||
reopen?: boolean;
|
||||
interrupt?: boolean;
|
||||
reassignment: CommentReassignment;
|
||||
}) =>
|
||||
issuesApi.update(issueId!, {
|
||||
|
|
@ -509,13 +633,96 @@ export function IssueDetail() {
|
|||
assigneeAgentId: reassignment.assigneeAgentId,
|
||||
assigneeUserId: reassignment.assigneeUserId,
|
||||
...(reopen ? { status: "todo" } : {}),
|
||||
...(interrupt ? { interrupt } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onMutate: async ({ body, reopen, reassignment, interrupt }) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt && runningIssueRun;
|
||||
const optimisticComment = issue
|
||||
? createOptimisticIssueComment({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
body,
|
||||
authorUserId: currentUserId,
|
||||
clientStatus: queuedComment ? "queued" : "pending",
|
||||
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (optimisticComment) {
|
||||
setOptimisticComments((current) => [...current, optimisticComment]);
|
||||
}
|
||||
if (previousIssue) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
onSuccess: (result, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
|
||||
const { comment, ...nextIssue } = result;
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
|
||||
if (comment) {
|
||||
queryClient.setQueryData<IssueComment[]>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => upsertIssueComment(current, comment),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
if (context?.previousIssue) {
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
||||
}
|
||||
pushToast({
|
||||
title: "Comment failed",
|
||||
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
},
|
||||
});
|
||||
|
||||
const interruptQueuedComment = useMutation({
|
||||
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
|
||||
onSuccess: () => {
|
||||
invalidateIssue();
|
||||
pushToast({
|
||||
title: "Interrupt requested",
|
||||
body: "The active run is stopping so queued comments can continue next.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Interrupt failed",
|
||||
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uploadAttachment = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
|
|
@ -578,9 +785,12 @@ export function IssueDetail() {
|
|||
// Redirect to identifier-based URL if navigated via UUID
|
||||
useEffect(() => {
|
||||
if (issue?.identifier && issueId !== issue.identifier) {
|
||||
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
|
||||
navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
});
|
||||
}
|
||||
}, [issue, issueId, navigate, location.state]);
|
||||
}, [issue, issueId, navigate, location.state, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.id) return;
|
||||
|
|
@ -692,7 +902,7 @@ export function IssueDetail() {
|
|||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Link
|
||||
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
||||
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
|
||||
state={location.state}
|
||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||
title={ancestor.title}
|
||||
|
|
@ -991,6 +1201,12 @@ export function IssueDetail() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<IssueWorkspaceCard
|
||||
issue={issue}
|
||||
project={orderedProjects.find((p) => p.id === issue.projectId) ?? null}
|
||||
onUpdate={(data) => updateIssue.mutate(data)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
|
||||
|
|
@ -1016,7 +1232,8 @@ export function IssueDetail() {
|
|||
|
||||
<TabsContent value="comments">
|
||||
<CommentThread
|
||||
comments={commentsWithRunMeta}
|
||||
comments={timelineComments}
|
||||
queuedComments={queuedComments}
|
||||
linkedRuns={timelineRuns}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId}
|
||||
|
|
@ -1028,6 +1245,10 @@ export function IssueDetail() {
|
|||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
onInterruptQueued={async (runId) => {
|
||||
await interruptQueuedComment.mutateAsync(runId);
|
||||
}}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
|
|
@ -1054,7 +1275,7 @@ export function IssueDetail() {
|
|||
{childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
|
||||
state={location.state}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { useEffect, useMemo, useCallback } from "react";
|
||||
import { useLocation, useSearchParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
|
|
@ -22,28 +22,20 @@ export function Issues() {
|
|||
|
||||
const initialSearch = searchParams.get("q") ?? "";
|
||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const handleSearchChange = useCallback((search: string) => {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const trimmedSearch = search.trim();
|
||||
const currentSearch = new URLSearchParams(window.location.search).get("q") ?? "";
|
||||
if (currentSearch === trimmedSearch) return;
|
||||
const trimmedSearch = search.trim();
|
||||
const currentSearch = new URLSearchParams(window.location.search).get("q") ?? "";
|
||||
if (currentSearch === trimmedSearch) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
if (trimmedSearch) {
|
||||
url.searchParams.set("q", trimmedSearch);
|
||||
} else {
|
||||
url.searchParams.delete("q");
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
if (trimmedSearch) {
|
||||
url.searchParams.set("q", trimmedSearch);
|
||||
} else {
|
||||
url.searchParams.delete("q");
|
||||
}
|
||||
|
||||
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
}, []);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
|
|
@ -78,6 +70,7 @@ export function Issues() {
|
|||
createIssueDetailLocationState(
|
||||
"Issues",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
"issues",
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType
|
|||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"hermes_local",
|
||||
"openclaw_gateway",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ const adapterLabels: Record<string, string> = {
|
|||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
hermes_local: "Hermes",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
|
@ -14,20 +16,26 @@ import { useToast } from "../context/ToastContext";
|
|||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { projectRouteRef, cn } from "../lib/utils";
|
||||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { Clock3, Copy, GitBranch, Loader2 } from "lucide-react";
|
||||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
type ProjectBaseTab = "overview" | "list" | "configuration" | "budget";
|
||||
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
|
||||
type ProjectPluginTab = `plugin:${string}`;
|
||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||
|
||||
|
|
@ -44,6 +52,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
|||
if (tab === "configuration") return "configuration";
|
||||
if (tab === "budget") return "budget";
|
||||
if (tab === "issues") return "list";
|
||||
if (tab === "workspaces") return "workspaces";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +209,241 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
|||
);
|
||||
}
|
||||
|
||||
function ProjectWorkspacesContent({
|
||||
companyId,
|
||||
projectId,
|
||||
projectRef,
|
||||
summaries,
|
||||
}: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
projectRef: string;
|
||||
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(null);
|
||||
const [closingWorkspace, setClosingWorkspace] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: ExecutionWorkspace["status"];
|
||||
} | null>(null);
|
||||
const controlWorkspaceRuntime = useMutation({
|
||||
mutationFn: async (input: {
|
||||
key: string;
|
||||
kind: "project_workspace" | "execution_workspace";
|
||||
workspaceId: string;
|
||||
action: "start" | "stop" | "restart";
|
||||
}) => {
|
||||
setRuntimeActionKey(`${input.key}:${input.action}`);
|
||||
if (input.kind === "project_workspace") {
|
||||
return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
|
||||
}
|
||||
return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
|
||||
},
|
||||
onSettled: () => {
|
||||
setRuntimeActionKey(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
},
|
||||
});
|
||||
|
||||
if (summaries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
||||
}
|
||||
|
||||
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
|
||||
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
||||
|
||||
const renderSummaryRow = (summary: ReturnType<typeof buildProjectWorkspaceSummaries>[number]) => {
|
||||
const visibleIssues = summary.issues.slice(0, 3);
|
||||
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
||||
const workspaceHref =
|
||||
summary.kind === "project_workspace"
|
||||
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
|
||||
: `/execution-workspaces/${summary.workspaceId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={summary.key}
|
||||
className="border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto] md:items-start">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={workspaceHref}
|
||||
className="block truncate text-sm font-medium hover:underline"
|
||||
>
|
||||
{summary.workspaceName}
|
||||
</Link>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
<span className="font-mono">{summary.branchName ?? "No branch info"}</span>
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
|
||||
{summary.runningServiceCount}/{summary.serviceCount} services running
|
||||
</span>
|
||||
{summary.executionWorkspaceStatus ? (
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
|
||||
{summary.executionWorkspaceStatus}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{summary.primaryServiceUrl ? (
|
||||
<a
|
||||
href={summary.primaryServiceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
{summary.primaryServiceUrl}
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{summary.cwd ? (
|
||||
<div className="mt-2 flex min-w-0 items-start gap-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate font-mono leading-tight" title={summary.cwd}>
|
||||
{summary.cwd}
|
||||
</span>
|
||||
<CopyText text={summary.cwd} className="shrink-0" copiedLabel="Path copied">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Issues ({summary.issues.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-left text-xs leading-none transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="truncate leading-tight">{issue.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
{hiddenIssueCount > 0 ? (
|
||||
<span className="inline-flex items-center rounded-md border border-dashed border-border px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||
... and {hiddenIssueCount} more
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-start gap-2 md:items-end">
|
||||
<Link
|
||||
to={workspaceHref}
|
||||
className="text-xs font-medium text-foreground hover:underline"
|
||||
>
|
||||
{summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"}
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
controlWorkspaceRuntime.isPending
|
||||
|| !summary.hasRuntimeConfig
|
||||
|| runtimeActionKey !== null && runtimeActionKey !== `${summary.key}:start`
|
||||
}
|
||||
onClick={() =>
|
||||
controlWorkspaceRuntime.mutate({
|
||||
key: summary.key,
|
||||
kind: summary.kind,
|
||||
workspaceId: summary.workspaceId,
|
||||
action: "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
{runtimeActionKey === `${summary.key}:start` ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={controlWorkspaceRuntime.isPending || summary.serviceCount === 0}
|
||||
onClick={() =>
|
||||
controlWorkspaceRuntime.mutate({
|
||||
key: summary.key,
|
||||
kind: summary.kind,
|
||||
workspaceId: summary.workspaceId,
|
||||
action: "stop",
|
||||
})
|
||||
}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setClosingWorkspace({
|
||||
id: summary.executionWorkspaceId!,
|
||||
name: summary.workspaceName,
|
||||
status: summary.executionWorkspaceStatus!,
|
||||
})}
|
||||
>
|
||||
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close workspace"}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{timeAgo(summary.lastUpdatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{activeSummaries.map(renderSummaryRow)}
|
||||
</div>
|
||||
{cleanupFailedSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Cleanup attention needed
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5">
|
||||
{cleanupFailedSummaries.map(renderSummaryRow)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{closingWorkspace ? (
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
workspaceId={closingWorkspace.id}
|
||||
workspaceName={closingWorkspace.name}
|
||||
currentStatus={closingWorkspace.status}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setClosingWorkspace(null);
|
||||
}}
|
||||
onClosed={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||
setClosingWorkspace(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main project page ── */
|
||||
|
||||
export function ProjectDetail() {
|
||||
|
|
@ -241,6 +485,10 @@ export function ProjectDetail() {
|
|||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const projectLookupRef = project?.id ?? routeProjectRef;
|
||||
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
||||
const experimentalSettingsQuery = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const {
|
||||
slots: pluginDetailSlots,
|
||||
isLoading: pluginDetailSlotsLoading,
|
||||
|
|
@ -259,6 +507,39 @@ export function ProjectDetail() {
|
|||
[pluginDetailSlots],
|
||||
);
|
||||
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
||||
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const workspaceTabProjectId = project?.id ?? null;
|
||||
const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({
|
||||
queryKey: workspaceTabProjectId && resolvedCompanyId
|
||||
? queryKeys.issues.listByProject(resolvedCompanyId, workspaceTabProjectId)
|
||||
: ["issues", "__workspace-tab__", "disabled"],
|
||||
queryFn: () => issuesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
|
||||
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
const {
|
||||
data: workspaceTabExecutionWorkspaces = [],
|
||||
isLoading: isWorkspaceTabExecutionWorkspacesLoading,
|
||||
error: workspaceTabExecutionWorkspacesError,
|
||||
} = useQuery({
|
||||
queryKey: workspaceTabProjectId && resolvedCompanyId
|
||||
? queryKeys.executionWorkspaces.list(resolvedCompanyId, { projectId: workspaceTabProjectId })
|
||||
: ["execution-workspaces", "__workspace-tab__", "disabled"],
|
||||
queryFn: () => executionWorkspacesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
|
||||
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
|
||||
});
|
||||
const workspaceSummaries = useMemo(() => {
|
||||
if (!project || !isolatedWorkspacesEnabled) return [];
|
||||
return buildProjectWorkspaceSummaries({
|
||||
project,
|
||||
issues: workspaceTabIssues,
|
||||
executionWorkspaces: workspaceTabExecutionWorkspaces,
|
||||
});
|
||||
}, [project, isolatedWorkspacesEnabled, workspaceTabIssues, workspaceTabExecutionWorkspaces]);
|
||||
const showWorkspacesTab = isolatedWorkspacesEnabled && workspaceSummaries.length > 0;
|
||||
const workspaceTabDecisionLoaded =
|
||||
experimentalSettingsQuery.isFetched &&
|
||||
(!isolatedWorkspacesEnabled || (!isWorkspaceTabIssuesLoading && !isWorkspaceTabExecutionWorkspacesLoading));
|
||||
const workspaceTabError = (workspaceTabIssuesError ?? workspaceTabExecutionWorkspacesError) as Error | null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
|
|
@ -345,6 +626,10 @@ export function ProjectDetail() {
|
|||
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "workspaces") {
|
||||
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "list") {
|
||||
if (filter) {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||
|
|
@ -455,6 +740,10 @@ export function ProjectDetail() {
|
|||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
||||
if (activeTab === "workspaces" && workspaceTabDecisionLoaded && !showWorkspacesTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
||||
// Redirect bare /projects/:id to cached tab or default /issues
|
||||
if (routeProjectRef && activeTab === null) {
|
||||
let cachedTab: string | null = null;
|
||||
|
|
@ -470,6 +759,12 @@ export function ProjectDetail() {
|
|||
if (cachedTab === "budget") {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
||||
}
|
||||
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
|
||||
}
|
||||
if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) {
|
||||
return <PageSkeleton variant="detail" />;
|
||||
}
|
||||
if (isProjectPluginTab(cachedTab)) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
|
||||
}
|
||||
|
|
@ -491,6 +786,8 @@ export function ProjectDetail() {
|
|||
}
|
||||
if (tab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||
} else if (tab === "workspaces") {
|
||||
navigate(`/projects/${canonicalProjectRef}/workspaces`);
|
||||
} else if (tab === "budget") {
|
||||
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||||
} else if (tab === "configuration") {
|
||||
|
|
@ -561,6 +858,7 @@ export function ProjectDetail() {
|
|||
items={[
|
||||
{ value: "list", label: "Issues" },
|
||||
{ value: "overview", label: "Overview" },
|
||||
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
...pluginTabItems.map((item) => ({
|
||||
|
|
@ -589,6 +887,23 @@ export function ProjectDetail() {
|
|||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||||
)}
|
||||
|
||||
{activeTab === "workspaces" ? (
|
||||
workspaceTabDecisionLoaded ? (
|
||||
workspaceTabError ? (
|
||||
<p className="text-sm text-destructive">{workspaceTabError.message}</p>
|
||||
) : (
|
||||
<ProjectWorkspacesContent
|
||||
companyId={resolvedCompanyId!}
|
||||
projectId={project.id}
|
||||
projectRef={canonicalProjectRef}
|
||||
summaries={workspaceSummaries}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{activeTab === "configuration" && (
|
||||
<div className="max-w-4xl">
|
||||
<ProjectProperties
|
||||
|
|
|
|||
673
ui/src/pages/ProjectWorkspaceDetail.tsx
Normal file
673
ui/src/pages/ProjectWorkspaceDetail.tsx
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ChoosePathButton } from "../components/PathInstructionsModal";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
||||
|
||||
type WorkspaceFormState = {
|
||||
name: string;
|
||||
sourceType: ProjectWorkspaceSourceType;
|
||||
cwd: string;
|
||||
repoUrl: string;
|
||||
repoRef: string;
|
||||
defaultRef: string;
|
||||
visibility: ProjectWorkspaceVisibility;
|
||||
setupCommand: string;
|
||||
cleanupCommand: string;
|
||||
remoteProvider: string;
|
||||
remoteWorkspaceRef: string;
|
||||
sharedWorkspaceKey: string;
|
||||
runtimeConfig: string;
|
||||
};
|
||||
|
||||
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
|
||||
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
|
||||
|
||||
const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [
|
||||
{ value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." },
|
||||
{ value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." },
|
||||
{ value: "git_repo", label: "Remote git repo", description: "A repo URL with optional refs and local checkout." },
|
||||
{ value: "remote_managed", label: "Remote-managed workspace", description: "A hosted workspace tracked by external reference." },
|
||||
];
|
||||
|
||||
const VISIBILITY_OPTIONS: Array<{ value: ProjectWorkspaceVisibility; label: string }> = [
|
||||
{ value: "default", label: "Default" },
|
||||
{ value: "advanced", label: "Advanced" },
|
||||
];
|
||||
|
||||
function isSafeExternalUrl(value: string | null | undefined) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAbsolutePath(value: string) {
|
||||
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function readText(value: string | null | undefined) {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||
if (!value || Object.keys(value).length === 0) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState {
|
||||
return {
|
||||
name: workspace.name,
|
||||
sourceType: workspace.sourceType,
|
||||
cwd: readText(workspace.cwd),
|
||||
repoUrl: readText(workspace.repoUrl),
|
||||
repoRef: readText(workspace.repoRef),
|
||||
defaultRef: readText(workspace.defaultRef),
|
||||
visibility: workspace.visibility,
|
||||
setupCommand: readText(workspace.setupCommand),
|
||||
cleanupCommand: readText(workspace.cleanupCommand),
|
||||
remoteProvider: readText(workspace.remoteProvider),
|
||||
remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef),
|
||||
sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey),
|
||||
runtimeConfig: formatJson(workspace.runtimeConfig?.workspaceRuntime),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeText(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseRuntimeConfigJson(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return { ok: true as const, value: null as Record<string, unknown> | null };
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: "Runtime services JSON must be a JSON object.",
|
||||
};
|
||||
}
|
||||
return { ok: true as const, value: parsed as Record<string, unknown> };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error instanceof Error ? error.message : "Invalid JSON.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const maybeAssign = (key: keyof WorkspaceFormState, transform?: (value: string) => unknown) => {
|
||||
const initialValue = initialState[key];
|
||||
const nextValue = nextState[key];
|
||||
if (initialValue === nextValue) return;
|
||||
patch[key] = transform ? transform(nextValue) : nextValue;
|
||||
};
|
||||
|
||||
maybeAssign("name", normalizeText);
|
||||
maybeAssign("sourceType");
|
||||
maybeAssign("cwd", normalizeText);
|
||||
maybeAssign("repoUrl", normalizeText);
|
||||
maybeAssign("repoRef", normalizeText);
|
||||
maybeAssign("defaultRef", normalizeText);
|
||||
maybeAssign("visibility");
|
||||
maybeAssign("setupCommand", normalizeText);
|
||||
maybeAssign("cleanupCommand", normalizeText);
|
||||
maybeAssign("remoteProvider", normalizeText);
|
||||
maybeAssign("remoteWorkspaceRef", normalizeText);
|
||||
maybeAssign("sharedWorkspaceKey", normalizeText);
|
||||
if (initialState.runtimeConfig !== nextState.runtimeConfig) {
|
||||
const parsed = parseRuntimeConfigJson(nextState.runtimeConfig);
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
patch.runtimeConfig = {
|
||||
workspaceRuntime: parsed.value,
|
||||
};
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function validateWorkspaceForm(form: WorkspaceFormState) {
|
||||
const cwd = normalizeText(form.cwd);
|
||||
const repoUrl = normalizeText(form.repoUrl);
|
||||
const remoteWorkspaceRef = normalizeText(form.remoteWorkspaceRef);
|
||||
|
||||
if (form.sourceType === "remote_managed") {
|
||||
if (!remoteWorkspaceRef && !repoUrl) {
|
||||
return "Remote-managed workspaces require a remote workspace ref or repo URL.";
|
||||
}
|
||||
} else if (!cwd && !repoUrl) {
|
||||
return "Workspace requires at least one local path or repo URL.";
|
||||
}
|
||||
|
||||
if (cwd && (form.sourceType === "local_path" || form.sourceType === "non_git_path") && !isAbsolutePath(cwd)) {
|
||||
return "Local workspace path must be absolute.";
|
||||
}
|
||||
|
||||
if (repoUrl) {
|
||||
try {
|
||||
new URL(repoUrl);
|
||||
} catch {
|
||||
return "Repo URL must be a valid URL.";
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeConfig = parseRuntimeConfigJson(form.runtimeConfig);
|
||||
if (!runtimeConfig.ok) {
|
||||
return runtimeConfig.error;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
|
||||
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
|
||||
<div className="shrink-0 text-xs text-muted-foreground sm:w-28">{label}</div>
|
||||
<div className="min-w-0 flex-1 text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectWorkspaceDetail() {
|
||||
const { companyPrefix, projectId, workspaceId } = useParams<{
|
||||
companyPrefix?: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<WorkspaceFormState | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
const routeProjectRef = projectId ?? "";
|
||||
const routeWorkspaceId = workspaceId ?? "";
|
||||
|
||||
const routeCompanyId = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
|
||||
}, [companies, companyPrefix]);
|
||||
|
||||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||||
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
||||
const projectQuery = useQuery({
|
||||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||||
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
|
||||
enabled: canFetchProject,
|
||||
});
|
||||
|
||||
const project = projectQuery.data ?? null;
|
||||
const workspace = useMemo(
|
||||
() => project?.workspaces.find((item) => item.id === routeWorkspaceId) ?? null,
|
||||
[project, routeWorkspaceId],
|
||||
);
|
||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
|
||||
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
setSelectedCompanyId(project.companyId, { source: "route_sync" });
|
||||
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
setForm(formStateFromWorkspace(workspace));
|
||||
setErrorMessage(null);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
setBreadcrumbs([
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: project.name, href: `/projects/${canonicalProjectRef}` },
|
||||
{ label: "Workspaces", href: `/projects/${canonicalProjectRef}/workspaces` },
|
||||
{ label: workspace?.name ?? routeWorkspaceId },
|
||||
]);
|
||||
}, [setBreadcrumbs, project, canonicalProjectRef, workspace?.name, routeWorkspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
if (routeProjectRef === canonicalProjectRef) return;
|
||||
navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true });
|
||||
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]);
|
||||
|
||||
const invalidateProject = () => {
|
||||
if (!project) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
|
||||
if (lookupCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(lookupCompanyId) });
|
||||
}
|
||||
};
|
||||
|
||||
const updateWorkspace = useMutation({
|
||||
mutationFn: (patch: Record<string, unknown>) =>
|
||||
projectsApi.updateWorkspace(project!.id, routeWorkspaceId, patch, lookupCompanyId),
|
||||
onSuccess: () => {
|
||||
invalidateProject();
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to save workspace.");
|
||||
},
|
||||
});
|
||||
|
||||
const setPrimaryWorkspace = useMutation({
|
||||
mutationFn: () => projectsApi.updateWorkspace(project!.id, routeWorkspaceId, { isPrimary: true }, lookupCompanyId),
|
||||
onSuccess: () => {
|
||||
invalidateProject();
|
||||
setErrorMessage(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to update workspace.");
|
||||
},
|
||||
});
|
||||
|
||||
const controlRuntimeServices = useMutation({
|
||||
mutationFn: (action: "start" | "stop" | "restart") =>
|
||||
projectsApi.controlWorkspaceRuntimeServices(project!.id, routeWorkspaceId, action, lookupCompanyId),
|
||||
onSuccess: (result, action) => {
|
||||
invalidateProject();
|
||||
setErrorMessage(null);
|
||||
setRuntimeActionMessage(
|
||||
action === "stop"
|
||||
? "Runtime services stopped."
|
||||
: action === "restart"
|
||||
? "Runtime services restarted."
|
||||
: "Runtime services started.",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
setRuntimeActionMessage(null);
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services.");
|
||||
},
|
||||
});
|
||||
|
||||
if (projectQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace…</p>;
|
||||
if (projectQuery.error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
{projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load workspace"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!project || !workspace || !form || !initialState) {
|
||||
return <p className="text-sm text-muted-foreground">Workspace not found for this project.</p>;
|
||||
}
|
||||
|
||||
const saveChanges = () => {
|
||||
const validationError = validateWorkspaceForm(form);
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
const patch = buildWorkspacePatch(initialState, form);
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
updateWorkspace.mutate(patch);
|
||||
};
|
||||
|
||||
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/projects/${canonicalProjectRef}/workspaces`}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back to workspaces
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Project workspace
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
|
||||
checkout behavior, default runtime services for child execution workspaces, and let you override setup
|
||||
or cleanup commands when one workspace needs special handling.
|
||||
</p>
|
||||
</div>
|
||||
{!workspace.isPrimary ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={setPrimaryWorkspace.isPending}
|
||||
onClick={() => setPrimaryWorkspace.mutate()}
|
||||
>
|
||||
{setPrimaryWorkspace.isPending
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <Check className="mr-2 h-4 w-4" />}
|
||||
Make primary
|
||||
</Button>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
This is the project’s primary codebase workspace.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-5" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Workspace name">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||
placeholder="Workspace name"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Visibility">
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.visibility}
|
||||
onChange={(event) =>
|
||||
setForm((current) => current ? { ...current, visibility: event.target.value as ProjectWorkspaceVisibility } : current)
|
||||
}
|
||||
>
|
||||
{VISIBILITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
<Field label="Source type" hint={sourceTypeDescription ?? undefined}>
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.sourceType}
|
||||
onChange={(event) =>
|
||||
setForm((current) => current ? { ...current, sourceType: event.target.value as ProjectWorkspaceSourceType } : current)
|
||||
}
|
||||
>
|
||||
{SOURCE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Field label="Local path">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cwd}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
|
||||
placeholder="/absolute/path/to/workspace"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-end">
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Repo URL">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.repoUrl}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Repo ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.repoRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, repoRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Default ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.defaultRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, defaultRef: event.target.value } : current)}
|
||||
placeholder="origin/main"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Shared workspace key">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.sharedWorkspaceKey}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, sharedWorkspaceKey: event.target.value } : current)}
|
||||
placeholder="frontend"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Remote provider">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||
value={form.remoteProvider}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, remoteProvider: event.target.value } : current)}
|
||||
placeholder="codespaces"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Remote workspace ref">
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.remoteWorkspaceRef}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, remoteWorkspaceRef: event.target.value } : current)}
|
||||
placeholder="workspace-123"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Setup command" hint="Runs when this workspace needs custom bootstrap">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.setupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, setupCommand: event.target.value } : current)}
|
||||
placeholder="pnpm install && pnpm dev"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Cleanup command" hint="Runs before project-level execution workspace teardown">
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.cleanupCommand}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
|
||||
placeholder="pkill -f vite || true"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Runtime services JSON" hint="Default runtime services for this workspace. Execution workspaces inherit this config unless they set an override. If you do not know the commands yet, ask your CEO to configure them for you.">
|
||||
<textarea
|
||||
className="min-h-36 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
|
||||
value={form.runtimeConfig}
|
||||
onChange={(event) => setForm((current) => current ? { ...current, runtimeConfig: event.target.value } : current)}
|
||||
placeholder={"{\n \"services\": [\n {\n \"name\": \"web\",\n \"command\": \"pnpm dev\",\n \"cwd\": \".\",\n \"port\": { \"type\": \"auto\" },\n \"readiness\": {\n \"type\": \"http\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"expose\": {\n \"type\": \"url\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"lifecycle\": \"shared\",\n \"reuseScope\": \"project_workspace\"\n }\n ]\n}"}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
|
||||
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!isDirty || updateWorkspace.isPending}
|
||||
onClick={() => {
|
||||
setForm(initialState);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
|
||||
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
|
||||
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace facts</div>
|
||||
<h2 className="text-lg font-semibold">Current state</h2>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<DetailRow label="Project">
|
||||
<Link to={`/projects/${canonicalProjectRef}`} className="hover:underline">{project.name}</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Workspace ID">
|
||||
<span className="break-all font-mono text-xs">{workspace.id}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Local path">
|
||||
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Repo">
|
||||
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
|
||||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{workspace.repoUrl}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : workspace.repoUrl ? (
|
||||
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
|
||||
) : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Default ref">{workspace.defaultRef ?? "None"}</DetailRow>
|
||||
<DetailRow label="Updated">{new Date(workspace.updatedAt).toLocaleString()}</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
|
||||
<h2 className="text-lg font-semibold">Attached services</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shared services for this project workspace. Execution workspaces inherit this config unless they override it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !workspace.runtimeConfig?.workspaceRuntime || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("start")}
|
||||
>
|
||||
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || !workspace.cwd}
|
||||
onClick={() => controlRuntimeServices.mutate("restart")}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspace.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{service.serviceName}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{service.url ? (
|
||||
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
||||
{service.url}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
{service.port ? <div>Port {service.port}</div> : null}
|
||||
<div>{service.command ?? "No command recorded"}</div>
|
||||
{service.cwd ? <div className="break-all font-mono">{service.cwd}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground sm:text-right">
|
||||
{service.status} · {service.healthStatus}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{workspace.runtimeConfig?.workspaceRuntime
|
||||
? "No runtime services are currently running for this workspace."
|
||||
: "No runtime-service default is configured for this workspace yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -251,8 +251,11 @@ export function Routines() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="max-w-3xl gap-0 overflow-hidden p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[calc(100dvh-2rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -272,197 +275,199 @@ export function Routines() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Routine title"
|
||||
rows={1}
|
||||
value={draft.title}
|
||||
onChange={(event) => {
|
||||
setDraft((current) => ({ ...current, title: event.target.value }));
|
||||
autoResizeTextarea(event.target);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (draft.assigneeAgentId) {
|
||||
if (draft.projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
assigneeSelectorRef.current?.focus();
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="px-5 pt-5 pb-3">
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Routine title"
|
||||
rows={1}
|
||||
value={draft.title}
|
||||
onChange={(event) => {
|
||||
setDraft((current) => ({ ...current, title: event.target.value }));
|
||||
autoResizeTextarea(event.target);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-3">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
||||
<span>For</span>
|
||||
<InlineEntitySelector
|
||||
ref={assigneeSelectorRef}
|
||||
value={draft.assigneeAgentId}
|
||||
options={assigneeOptions}
|
||||
placeholder="Assignee"
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={(assigneeAgentId) => {
|
||||
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
|
||||
setDraft((current) => ({ ...current, assigneeAgentId }));
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (draft.projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
if (event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (draft.assigneeAgentId) {
|
||||
if (draft.projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
assigneeSelectorRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
renderTriggerValue={(option) =>
|
||||
option ? (
|
||||
currentAssignee ? (
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-3">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
||||
<span>For</span>
|
||||
<InlineEntitySelector
|
||||
ref={assigneeSelectorRef}
|
||||
value={draft.assigneeAgentId}
|
||||
options={assigneeOptions}
|
||||
placeholder="Assignee"
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={(assigneeAgentId) => {
|
||||
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
|
||||
setDraft((current) => ({ ...current, assigneeAgentId }));
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (draft.projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
renderTriggerValue={(option) =>
|
||||
option ? (
|
||||
currentAssignee ? (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{option.label}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">Assignee</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const assignee = agentById.get(option.id);
|
||||
return (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span>in</span>
|
||||
<InlineEntitySelector
|
||||
ref={projectSelectorRef}
|
||||
value={draft.projectId}
|
||||
options={projectOptions}
|
||||
placeholder="Project"
|
||||
noneLabel="No project"
|
||||
searchPlaceholder="Search projects..."
|
||||
emptyMessage="No projects found."
|
||||
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
|
||||
onConfirm={() => descriptionEditorRef.current?.focus()}
|
||||
renderTriggerValue={(option) =>
|
||||
option && currentProject ? (
|
||||
<>
|
||||
<span
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{option.label}</span>
|
||||
<span className="text-muted-foreground">Project</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">Assignee</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const assignee = agentById.get(option.id);
|
||||
return (
|
||||
<>
|
||||
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span>in</span>
|
||||
<InlineEntitySelector
|
||||
ref={projectSelectorRef}
|
||||
value={draft.projectId}
|
||||
options={projectOptions}
|
||||
placeholder="Project"
|
||||
noneLabel="No project"
|
||||
searchPlaceholder="Search projects..."
|
||||
emptyMessage="No projects found."
|
||||
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
|
||||
onConfirm={() => descriptionEditorRef.current?.focus()}
|
||||
renderTriggerValue={(option) =>
|
||||
option && currentProject ? (
|
||||
<>
|
||||
<span
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Project</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const project = projectById.get(option.id);
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const project = projectById.get(option.id);
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 px-5 py-4">
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={draft.description}
|
||||
onChange={(description) => setDraft((current) => ({ ...current, description }))}
|
||||
placeholder="Add instructions..."
|
||||
bordered={false}
|
||||
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
||||
onSubmit={() => {
|
||||
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
||||
createRoutine.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 px-5 py-3">
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Advanced delivery settings</p>
|
||||
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
|
||||
</div>
|
||||
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
|
||||
<Select
|
||||
value={draft.concurrencyPolicy}
|
||||
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{concurrencyPolicies.map((value) => (
|
||||
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
|
||||
<Select
|
||||
value={draft.catchUpPolicy}
|
||||
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{catchUpPolicies.map((value) => (
|
||||
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 px-5 py-4">
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={draft.description}
|
||||
onChange={(description) => setDraft((current) => ({ ...current, description }))}
|
||||
placeholder="Add instructions..."
|
||||
bordered={false}
|
||||
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
||||
onSubmit={() => {
|
||||
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
||||
createRoutine.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 px-5 py-3">
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Advanced delivery settings</p>
|
||||
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
|
||||
</div>
|
||||
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
|
||||
<Select
|
||||
value={draft.concurrencyPolicy}
|
||||
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{concurrencyPolicies.map((value) => (
|
||||
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
|
||||
<Select
|
||||
value={draft.catchUpPolicy}
|
||||
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{catchUpPolicies.map((value) => (
|
||||
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs.
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue