diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f7f97eeb..5e307210 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,4 @@ -import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom"; +import { Navigate, Outlet, Route, Routes, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { Layout } from "./components/Layout"; import { authApi } from "./api/auth"; @@ -25,6 +25,7 @@ import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; import { queryKeys } from "./lib/queryKeys"; +import { useCompany } from "./context/CompanyContext"; function BootstrapPendingPage() { return ( @@ -127,6 +128,50 @@ function boardRoutes() { ); } +function CompanyRootRedirect() { + const { companies, selectedCompany, loading } = useCompany(); + + if (loading) { + return
Loading...
; + } + + const targetCompany = selectedCompany ?? companies[0] ?? null; + if (!targetCompany) { + return ( +
+ No accessible companies found. +
+ ); + } + + return ; +} + +function UnprefixedBoardRedirect() { + const location = useLocation(); + const { companies, selectedCompany, loading } = useCompany(); + + if (loading) { + return
Loading...
; + } + + const targetCompany = selectedCompany ?? companies[0] ?? null; + if (!targetCompany) { + return ( +
+ No accessible companies found. +
+ ); + } + + return ( + + ); +} + export function App() { return ( @@ -135,14 +180,21 @@ export function App() { } /> }> - {/* Company-prefixed routes: /PAP/issues/PAP-214 */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> }> {boardRoutes()} - {/* Non-prefixed routes: /issues/PAP-214 */} - }> - {boardRoutes()} - ); diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 17880103..31ada979 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -27,6 +27,12 @@ type AgentJoinRequestAccepted = JoinRequest & { claimSecret: string; claimApiKeyPath: string; onboarding?: Record; + diagnostics?: Array<{ + code: string; + level: "info" | "warn"; + message: string; + hint?: string; + }>; }; type InviteOnboardingManifest = { diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index fbeef4a4..dd73eb5b 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -8,7 +8,8 @@ import type { Approval, AgentConfigRevision, } from "@paperclip/shared"; -import { api } from "./client"; +import { isUuidLike, normalizeAgentUrlKey } from "@paperclip/shared"; +import { ApiError, api } from "./client"; export interface AgentKey { id: string; @@ -44,37 +45,78 @@ export interface AgentHireResponse { approval: Approval | null; } +function withCompanyScope(path: string, companyId?: string) { + if (!companyId) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}companyId=${encodeURIComponent(companyId)}`; +} + +function agentPath(id: string, companyId?: string, suffix = "") { + return withCompanyScope(`/agents/${encodeURIComponent(id)}${suffix}`, companyId); +} + export const agentsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/agents`), org: (companyId: string) => api.get(`/companies/${companyId}/org`), listConfigurations: (companyId: string) => api.get[]>(`/companies/${companyId}/agent-configurations`), - get: (id: string) => api.get(`/agents/${id}`), - getConfiguration: (id: string) => api.get>(`/agents/${id}/configuration`), - listConfigRevisions: (id: string) => - api.get(`/agents/${id}/config-revisions`), - getConfigRevision: (id: string, revisionId: string) => - api.get(`/agents/${id}/config-revisions/${revisionId}`), - rollbackConfigRevision: (id: string, revisionId: string) => - api.post(`/agents/${id}/config-revisions/${revisionId}/rollback`, {}), + get: async (id: string, companyId?: string) => { + try { + return await api.get(agentPath(id, companyId)); + } catch (error) { + // Backward-compat fallback: if backend shortname lookup reports ambiguity, + // resolve using company agent list while ignoring terminated agents. + if ( + !(error instanceof ApiError) || + error.status !== 409 || + !companyId || + isUuidLike(id) + ) { + throw error; + } + + const urlKey = normalizeAgentUrlKey(id); + if (!urlKey) throw error; + + const agents = await api.get(`/companies/${companyId}/agents`); + const matches = agents.filter( + (agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey, + ); + if (matches.length !== 1) throw error; + return api.get(agentPath(matches[0]!.id, companyId)); + } + }, + getConfiguration: (id: string, companyId?: string) => + api.get>(agentPath(id, companyId, "/configuration")), + listConfigRevisions: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/config-revisions")), + getConfigRevision: (id: string, revisionId: string, companyId?: string) => + api.get(agentPath(id, companyId, `/config-revisions/${revisionId}`)), + rollbackConfigRevision: (id: string, revisionId: string, companyId?: string) => + api.post(agentPath(id, companyId, `/config-revisions/${revisionId}/rollback`), {}), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/agents`, data), hire: (companyId: string, data: Record) => api.post(`/companies/${companyId}/agent-hires`, data), - update: (id: string, data: Record) => api.patch(`/agents/${id}`, data), - updatePermissions: (id: string, data: { canCreateAgents: boolean }) => - api.patch(`/agents/${id}/permissions`, data), - pause: (id: string) => api.post(`/agents/${id}/pause`, {}), - resume: (id: string) => api.post(`/agents/${id}/resume`, {}), - terminate: (id: string) => api.post(`/agents/${id}/terminate`, {}), - remove: (id: string) => api.delete<{ ok: true }>(`/agents/${id}`), - listKeys: (id: string) => api.get(`/agents/${id}/keys`), - createKey: (id: string, name: string) => api.post(`/agents/${id}/keys`, { name }), - revokeKey: (agentId: string, keyId: string) => api.delete<{ ok: true }>(`/agents/${agentId}/keys/${keyId}`), - runtimeState: (id: string) => api.get(`/agents/${id}/runtime-state`), - taskSessions: (id: string) => api.get(`/agents/${id}/task-sessions`), - resetSession: (id: string, taskKey?: string | null) => - api.post(`/agents/${id}/runtime-state/reset-session`, { taskKey: taskKey ?? null }), + update: (id: string, data: Record, companyId?: string) => + api.patch(agentPath(id, companyId), data), + updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) => + api.patch(agentPath(id, companyId, "/permissions"), data), + pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), + resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), + terminate: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/terminate"), {}), + remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)), + listKeys: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/keys")), + createKey: (id: string, name: string, companyId?: string) => + api.post(agentPath(id, companyId, "/keys"), { name }), + revokeKey: (agentId: string, keyId: string, companyId?: string) => + api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)), + runtimeState: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/runtime-state")), + taskSessions: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/task-sessions")), + resetSession: (id: string, taskKey?: string | null, companyId?: string) => + api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), adapterModels: (type: string) => api.get(`/adapters/${type}/models`), testEnvironment: ( companyId: string, @@ -85,7 +127,7 @@ export const agentsApi = { `/companies/${companyId}/adapters/${type}/test-environment`, data, ), - invoke: (id: string) => api.post(`/agents/${id}/heartbeat/invoke`, {}), + invoke: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/heartbeat/invoke"), {}), wakeup: ( id: string, data: { @@ -95,6 +137,8 @@ export const agentsApi = { payload?: Record | null; idempotencyKey?: string | null; }, - ) => api.post(`/agents/${id}/wakeup`, data), - loginWithClaude: (id: string) => api.post(`/agents/${id}/claude-login`, {}), + companyId?: string, + ) => api.post(agentPath(id, companyId, "/wakeup"), data), + loginWithClaude: (id: string, companyId?: string) => + api.post(agentPath(id, companyId, "/claude-login"), {}), }; diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 67325e30..4697a7ad 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -1,4 +1,11 @@ -import type { Company } from "@paperclip/shared"; +import type { + Company, + CompanyPortabilityExportResult, + CompanyPortabilityImportRequest, + CompanyPortabilityImportResult, + CompanyPortabilityPreviewRequest, + CompanyPortabilityPreviewResult, +} from "@paperclip/shared"; import { api } from "./client"; export type CompanyStats = Record; @@ -20,4 +27,10 @@ export const companiesApi = { ) => api.patch(`/companies/${companyId}`, data), archive: (companyId: string) => api.post(`/companies/${companyId}/archive`, {}), remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`), + exportBundle: (companyId: string, data: { include?: { company?: boolean; agents?: boolean } }) => + api.post(`/companies/${companyId}/export`, data), + importPreview: (data: CompanyPortabilityPreviewRequest) => + api.post("/companies/import/preview", data), + importBundle: (data: CompanyPortabilityImportRequest) => + api.post("/companies/import", data), }; diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index 991ef946..614bb522 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -4,6 +4,9 @@ export type HealthStatus = { deploymentExposure?: "private" | "public"; authReady?: boolean; bootstrapStatus?: "ready" | "bootstrap_pending"; + features?: { + companyDeletionEnabled?: boolean; + }; }; export const healthApi = { diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index cdd3446a..50c29258 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -39,8 +39,15 @@ export const issuesApi = { }), release: (id: string) => api.post(`/issues/${id}/release`, {}), listComments: (id: string) => api.get(`/issues/${id}/comments`), - addComment: (id: string, body: string, reopen?: boolean) => - api.post(`/issues/${id}/comments`, reopen === undefined ? { body } : { body, reopen }), + addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean) => + api.post( + `/issues/${id}/comments`, + { + body, + ...(reopen === undefined ? {} : { reopen }), + ...(interrupt === undefined ? {} : { interrupt }), + }, + ), listAttachments: (id: string) => api.get(`/issues/${id}/attachments`), uploadAttachment: ( companyId: string, diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index 0c68d5d7..80d26f58 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,19 +1,33 @@ import type { Project, ProjectWorkspace } from "@paperclip/shared"; import { api } from "./client"; +function withCompanyScope(path: string, companyId?: string) { + if (!companyId) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}companyId=${encodeURIComponent(companyId)}`; +} + +function projectPath(id: string, companyId?: string, suffix = "") { + return withCompanyScope(`/projects/${encodeURIComponent(id)}${suffix}`, companyId); +} + export const projectsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/projects`), - get: (id: string) => api.get(`/projects/${id}`), + get: (id: string, companyId?: string) => api.get(projectPath(id, companyId)), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/projects`, data), - update: (id: string, data: Record) => api.patch(`/projects/${id}`, data), - listWorkspaces: (projectId: string) => - api.get(`/projects/${projectId}/workspaces`), - createWorkspace: (projectId: string, data: Record) => - api.post(`/projects/${projectId}/workspaces`, data), - updateWorkspace: (projectId: string, workspaceId: string, data: Record) => - api.patch(`/projects/${projectId}/workspaces/${workspaceId}`, data), - removeWorkspace: (projectId: string, workspaceId: string) => - api.delete(`/projects/${projectId}/workspaces/${workspaceId}`), - remove: (id: string) => api.delete(`/projects/${id}`), + update: (id: string, data: Record, companyId?: string) => + api.patch(projectPath(id, companyId), data), + listWorkspaces: (projectId: string, companyId?: string) => + api.get(projectPath(projectId, companyId, "/workspaces")), + createWorkspace: (projectId: string, data: Record, companyId?: string) => + api.post(projectPath(projectId, companyId, "/workspaces"), data), + updateWorkspace: (projectId: string, workspaceId: string, data: Record, companyId?: string) => + api.patch( + projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`), + data, + ), + removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) => + api.delete(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)), + remove: (id: string, companyId?: string) => api.delete(projectPath(id, companyId)), }; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 1b0b7483..b6b767ee 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import type { Issue, LiveEvent } from "@paperclip/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index bf8258e6..ab600fac 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -1,9 +1,8 @@ -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { Identity } from "./Identity"; import { timeAgo } from "../lib/timeAgo"; import { cn } from "../lib/utils"; -import type { ActivityEvent } from "@paperclip/shared"; -import type { Agent } from "@paperclip/shared"; +import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclip/shared"; const ACTION_VERBS: Record = { "issue.created": "created", @@ -70,7 +69,7 @@ function entityLink(entityType: string, entityId: string, name?: string | null): switch (entityType) { case "issue": return `/issues/${name ?? entityId}`; case "agent": return `/agents/${entityId}`; - case "project": return `/projects/${entityId}`; + case "project": return `/projects/${deriveProjectUrlKey(name, entityId)}`; case "goal": return `/goals/${entityId}`; case "approval": return `/approvals/${entityId}`; default: return null; diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index d9005e63..d9445879 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -1,12 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import type { Agent, AgentRuntimeState } from "@paperclip/shared"; import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { StatusBadge } from "./StatusBadge"; import { Identity } from "./Identity"; -import { formatDate } from "../lib/utils"; +import { formatDate, agentUrl } from "../lib/utils"; import { Separator } from "@/components/ui/separator"; interface AgentPropertiesProps { @@ -84,7 +84,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { {agent.reportsTo && ( {reportsToAgent ? ( - + ) : ( diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx index 54487425..19512fdd 100644 --- a/ui/src/components/ApprovalCard.tsx +++ b/ui/src/components/ApprovalCard.tsx @@ -1,5 +1,5 @@ import { CheckCircle2, XCircle, Clock } from "lucide-react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { Button } from "@/components/ui/button"; import { Identity } from "./Identity"; import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload"; diff --git a/ui/src/components/AsciiArtAnimation.tsx b/ui/src/components/AsciiArtAnimation.tsx index f46941e2..f71d2fba 100644 --- a/ui/src/components/AsciiArtAnimation.tsx +++ b/ui/src/components/AsciiArtAnimation.tsx @@ -1,16 +1,45 @@ import { useEffect, useRef } from "react"; -const CHARS = "░▒▓█▄▀■□▪▫●○◆◇◈◉★☆✦✧·."; +const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const; +const TARGET_FPS = 24; +const FRAME_INTERVAL_MS = 1000 / TARGET_FPS; -interface Particle { +const PAPERCLIP_SPRITES = [ + [ + " ╭────╮ ", + " ╭╯╭──╮│ ", + " │ │ ││ ", + " │ │ ││ ", + " │ │ ││ ", + " │ │ ││ ", + " │ ╰──╯│ ", + " ╰─────╯ ", + ], + [ + " ╭─────╮ ", + " │╭──╮╰╮ ", + " ││ │ │ ", + " ││ │ │ ", + " ││ │ │ ", + " ││ │ │ ", + " │╰──╯ │ ", + " ╰────╯ ", + ], +] as const; + +type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number]; + +interface Clip { x: number; y: number; vx: number; vy: number; - char: string; life: number; maxLife: number; - phase: number; + drift: number; + sprite: PaperclipSprite; + width: number; + height: number; } function measureChar(container: HTMLElement): { w: number; h: number } { @@ -24,167 +53,287 @@ function measureChar(container: HTMLElement): { w: number; h: number } { return { w: rect.width, h: rect.height }; } +function spriteSize(sprite: PaperclipSprite): { width: number; height: number } { + let width = 0; + for (const row of sprite) width = Math.max(width, row.length); + return { width, height: sprite.length }; +} + export function AsciiArtAnimation() { const preRef = useRef(null); - const frameRef = useRef(0); - const particlesRef = useRef([]); + const frameRef = useRef(null); useEffect(() => { if (!preRef.current) return; const preEl: HTMLPreElement = preRef.current; + const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)"); + let isVisible = document.visibilityState !== "hidden"; + let loopActive = false; + let lastRenderAt = 0; + let tick = 0; + let cols = 0; + let rows = 0; + let charW = 7; + let charH = 11; + let trail = new Float32Array(0); + let colWave = new Float32Array(0); + let rowWave = new Float32Array(0); + let clipMask = new Uint16Array(0); + let clips: Clip[] = []; + let lastOutput = ""; - const charSize = measureChar(preEl); - let charW = charSize.w; - let charH = charSize.h; - let cols = Math.ceil(preEl.clientWidth / charW); - let rows = Math.ceil(preEl.clientHeight / charH); - let particles = particlesRef.current; + function toGlyph(value: number): string { + const clamped = Math.max(0, Math.min(0.999, value)); + const idx = Math.floor(clamped * CHARS.length); + return CHARS[idx] ?? " "; + } - function spawnParticle() { - const edge = Math.random(); - let x: number, y: number, vx: number, vy: number; - if (edge < 0.5) { - x = -1; - y = Math.random() * rows; - vx = 0.3 + Math.random() * 0.5; - vy = (Math.random() - 0.5) * 0.2; - } else { - x = Math.random() * cols; - y = rows + 1; - vx = (Math.random() - 0.5) * 0.2; - vy = -(0.2 + Math.random() * 0.4); + function rebuildGrid() { + const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW))); + const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH))); + if (nextCols === cols && nextRows === rows) return; + + cols = nextCols; + rows = nextRows; + const cellCount = cols * rows; + trail = new Float32Array(cellCount); + colWave = new Float32Array(cols); + rowWave = new Float32Array(rows); + clipMask = new Uint16Array(cellCount); + clips = clips.filter((clip) => { + return ( + clip.x > -clip.width - 2 && + clip.x < cols + 2 && + clip.y > -clip.height - 2 && + clip.y < rows + 2 + ); + }); + lastOutput = ""; + } + + function drawStaticFrame() { + if (cols <= 0 || rows <= 0) { + preEl.textContent = ""; + return; } - const maxLife = 60 + Math.random() * 120; - particles.push({ - x, y, vx, vy, - char: CHARS[Math.floor(Math.random() * CHARS.length)], + + const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " ")); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22; + grid[r][c] = toGlyph(ambient); + } + } + + const gapX = 18; + const gapY = 13; + for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) { + const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10; + for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) { + const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!; + for (let sr = 0; sr < sprite.length; sr++) { + const line = sprite[sr]!; + for (let sc = 0; sc < line.length; sc++) { + const ch = line[sc] ?? " "; + if (ch === " ") continue; + const row = baseRow + sr; + const col = baseCol + sc; + if (row < 0 || row >= rows || col < 0 || col >= cols) continue; + grid[row]![col] = ch; + } + } + } + } + + const output = grid.map((line) => line.join("")).join("\n"); + preEl.textContent = output; + lastOutput = output; + } + + function spawnClip() { + const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!; + const size = spriteSize(sprite); + const edge = Math.random(); + let x = 0; + let y = 0; + let vx = 0; + let vy = 0; + + if (edge < 0.68) { + x = Math.random() < 0.5 ? -size.width - 1 : cols + 1; + y = Math.random() * Math.max(1, rows - size.height); + vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05); + vy = (Math.random() - 0.5) * 0.014; + } else { + x = Math.random() * Math.max(1, cols - size.width); + y = Math.random() < 0.5 ? -size.height - 1 : rows + 1; + vx = (Math.random() - 0.5) * 0.014; + vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034); + } + + clips.push({ + x, + y, + vx, + vy, life: 0, - maxLife, - phase: Math.random() * Math.PI * 2, + maxLife: 260 + Math.random() * 220, + drift: (Math.random() - 0.5) * 1.2, + sprite, + width: size.width, + height: size.height, }); } - function render(time: number) { - const t = time * 0.001; - - // Spawn particles - const targetCount = Math.floor((cols * rows) / 12); - while (particles.length < targetCount) { - spawnParticle(); - } - - // Build grid - const grid: string[][] = Array.from({ length: rows }, () => - Array.from({ length: cols }, () => " ") - ); - const opacity: number[][] = Array.from({ length: rows }, () => - Array.from({ length: cols }, () => 0) - ); - - // Background wave pattern - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - const wave = - Math.sin(c * 0.08 + t * 0.7 + r * 0.04) * - Math.sin(r * 0.06 - t * 0.5) * - Math.cos((c + r) * 0.03 + t * 0.3); - if (wave > 0.65) { - grid[r][c] = wave > 0.85 ? "·" : "."; - opacity[r][c] = Math.min(1, (wave - 0.65) * 3); - } + function stampClip(clip: Clip, alpha: number) { + const baseCol = Math.round(clip.x); + const baseRow = Math.round(clip.y); + for (let sr = 0; sr < clip.sprite.length; sr++) { + const line = clip.sprite[sr]!; + const row = baseRow + sr; + if (row < 0 || row >= rows) continue; + for (let sc = 0; sc < line.length; sc++) { + const ch = line[sc] ?? " "; + if (ch === " ") continue; + const col = baseCol + sc; + if (col < 0 || col >= cols) continue; + const idx = row * cols + col; + const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92; + trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke); + clipMask[idx] = ch.charCodeAt(0); } } + } - // Update and render particles - for (let i = particles.length - 1; i >= 0; i--) { - const p = particles[i]; - p.life++; + function step(time: number) { + if (!loopActive) return; + frameRef.current = requestAnimationFrame(step); + if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return; - // Flow field influence - const angle = - Math.sin(p.x * 0.05 + t * 0.3) * Math.cos(p.y * 0.07 - t * 0.2) * - Math.PI; - p.vx += Math.cos(angle) * 0.02; - p.vy += Math.sin(angle) * 0.02; + const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667); + lastRenderAt = time; + tick += delta; - // Damping - p.vx *= 0.98; - p.vy *= 0.98; + const cellCount = cols * rows; + const targetCount = Math.max(3, Math.floor(cellCount / 2200)); + while (clips.length < targetCount) spawnClip(); - p.x += p.vx; - p.y += p.vy; + for (let i = 0; i < trail.length; i++) trail[i] *= 0.92; + clipMask.fill(0); - // Life fade - const lifeFrac = p.life / p.maxLife; - const alpha = lifeFrac < 0.1 - ? lifeFrac / 0.1 - : lifeFrac > 0.8 - ? (1 - lifeFrac) / 0.2 - : 1; + for (let i = clips.length - 1; i >= 0; i--) { + const clip = clips[i]!; + clip.life += delta; + + const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018; + const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014; + clip.vx = (clip.vx + wobbleX) * 0.998; + clip.vy = (clip.vy + wobbleY) * 0.998; + + clip.x += clip.vx * delta; + clip.y += clip.vy * delta; - // Remove dead or out-of-bounds particles if ( - p.life >= p.maxLife || - p.x < -2 || p.x > cols + 2 || - p.y < -2 || p.y > rows + 2 + clip.life >= clip.maxLife || + clip.x < -clip.width - 2 || + clip.x > cols + 2 || + clip.y < -clip.height - 2 || + clip.y > rows + 2 ) { - particles.splice(i, 1); + clips.splice(i, 1); continue; } - const col = Math.round(p.x); - const row = Math.round(p.y); - if (row >= 0 && row < rows && col >= 0 && col < cols) { - if (alpha > opacity[row][col]) { - // Cycle through characters based on life - const charIdx = Math.floor( - (lifeFrac + Math.sin(p.phase + t)) * CHARS.length - ) % CHARS.length; - grid[row][col] = CHARS[Math.abs(charIdx)]; - opacity[row][col] = alpha; - } - } + const life = clip.life / clip.maxLife; + const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1; + stampClip(clip, alpha); } - // Render to string + for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06); + for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05); + let output = ""; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { - const a = opacity[r][c]; - if (a > 0 && grid[r][c] !== " ") { - const o = Math.round(a * 60 + 40); - output += `${grid[r][c]}`; - } else { - output += " "; + const idx = r * cols + c; + const clipChar = clipMask[idx]; + if (clipChar > 0) { + output += String.fromCharCode(clipChar); + continue; } + const ambient = (colWave[c] + rowWave[r]) * 0.08 + 0.1; + const intensity = Math.max(trail[idx] ?? 0, ambient * 0.45); + output += toGlyph(intensity); } if (r < rows - 1) output += "\n"; } - preEl.innerHTML = output; - frameRef.current = requestAnimationFrame(render); + if (output !== lastOutput) { + preEl.textContent = output; + lastOutput = output; + } + } + + function syncLoop() { + const canRender = cols > 0 && rows > 0; + if (motionMedia.matches) { + if (loopActive) { + loopActive = false; + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + if (canRender) drawStaticFrame(); + return; + } + + if (!isVisible || !canRender) { + if (loopActive) { + loopActive = false; + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + return; + } + + if (!loopActive) { + loopActive = true; + lastRenderAt = 0; + frameRef.current = requestAnimationFrame(step); + } } - // Handle resize const observer = new ResizeObserver(() => { const size = measureChar(preEl); charW = size.w; charH = size.h; - cols = Math.ceil(preEl.clientWidth / charW); - rows = Math.ceil(preEl.clientHeight / charH); - // Cull out-of-bounds particles on resize - particles = particles.filter( - (p) => p.x >= -2 && p.x <= cols + 2 && p.y >= -2 && p.y <= rows + 2 - ); - particlesRef.current = particles; + rebuildGrid(); + syncLoop(); }); observer.observe(preEl); - frameRef.current = requestAnimationFrame(render); + const onVisibilityChange = () => { + isVisible = document.visibilityState !== "hidden"; + syncLoop(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + + const onMotionChange = () => { + syncLoop(); + }; + motionMedia.addEventListener("change", onMotionChange); + + const charSize = measureChar(preEl); + charW = charSize.w; + charH = charSize.h; + rebuildGrid(); + syncLoop(); return () => { - cancelAnimationFrame(frameRef.current); + loopActive = false; + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); observer.disconnect(); + document.removeEventListener("visibilitychange", onVisibilityChange); + motionMedia.removeEventListener("change", onMotionChange); }; }, []); diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index e627099e..a93d96c8 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { Menu } from "lucide-react"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useSidebar } from "../context/SidebarContext"; @@ -25,6 +25,7 @@ export function BreadcrumbBar() { size="icon-sm" className="mr-2 shrink-0" onClick={toggleSidebar} + aria-label="Open sidebar" > diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index b88c36b1..d4199e7e 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; @@ -29,6 +29,7 @@ import { Plus, } from "lucide-react"; import { Identity } from "./Identity"; +import { agentUrl, projectUrl } from "../lib/utils"; export function CommandPalette() { const [open, setOpen] = useState(false); @@ -174,10 +175,9 @@ export function CommandPalette() { key={issue.id} value={ searchQuery.length > 0 - ? `${searchQuery} ${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}` + ? `${searchQuery} ${issue.identifier ?? ""} ${issue.title}` : undefined } - keywords={issue.description ? [issue.description] : undefined} onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)} > @@ -200,7 +200,7 @@ export function CommandPalette() { {agents.slice(0, 10).map((agent) => ( - go(`/agents/${agent.id}`)}> + go(agentUrl(agent))}> {agent.name} {agent.role} @@ -215,7 +215,7 @@ export function CommandPalette() { {projects.slice(0, 10).map((project) => ( - go(`/projects/${project.id}`)}> + go(projectUrl(project))}> {project.name} diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 04770686..d8bef454 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Paperclip, Plus } from "lucide-react"; +import { useQueries } from "@tanstack/react-query"; import { DndContext, closestCenter, @@ -18,6 +19,9 @@ import { CSS } from "@dnd-kit/utilities"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { cn } from "../lib/utils"; +import { queryKeys } from "../lib/queryKeys"; +import { sidebarBadgesApi } from "../api/sidebarBadges"; +import { heartbeatsApi } from "../api/heartbeats"; import { Tooltip, TooltipContent, @@ -65,10 +69,14 @@ function sortByStoredOrder(companies: Company[]): Company[] { function SortableCompanyItem({ company, isSelected, + hasLiveAgents, + hasUnreadInbox, onSelect, }: { company: Company; isSelected: boolean; + hasLiveAgents: boolean; + hasUnreadInbox: boolean; onSelect: () => void; }) { const { @@ -88,28 +96,28 @@ function SortableCompanyItem({ }; return ( -
+
{ e.preventDefault(); onSelect(); }} - className="relative flex items-center justify-center group" + className="relative flex items-center justify-center group overflow-visible" > {/* Selection indicator pill */}
+ {hasLiveAgents && ( + + + + + + + )} + {hasUnreadInbox && ( + + )}
@@ -139,6 +158,36 @@ export function CompanyRail() { () => companies.filter((company) => company.status !== "archived"), [companies], ); + const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]); + + const liveRunsQueries = useQueries({ + queries: companyIds.map((companyId) => ({ + queryKey: queryKeys.liveRuns(companyId), + queryFn: () => heartbeatsApi.liveRunsForCompany(companyId), + refetchInterval: 10_000, + })), + }); + const sidebarBadgeQueries = useQueries({ + queries: companyIds.map((companyId) => ({ + queryKey: queryKeys.sidebarBadges(companyId), + queryFn: () => sidebarBadgesApi.get(companyId), + refetchInterval: 15_000, + })), + }); + const hasLiveAgentsByCompanyId = useMemo(() => { + const result = new Map(); + companyIds.forEach((companyId, index) => { + result.set(companyId, (liveRunsQueries[index]?.data?.length ?? 0) > 0); + }); + return result; + }, [companyIds, liveRunsQueries]); + const hasUnreadInboxByCompanyId = useMemo(() => { + const result = new Map(); + companyIds.forEach((companyId, index) => { + result.set(companyId, (sidebarBadgeQueries[index]?.data?.inbox ?? 0) > 0); + }); + return result; + }, [companyIds, sidebarBadgeQueries]); // Maintain sorted order in local state, synced from companies + localStorage const [orderedIds, setOrderedIds] = useState(() => @@ -219,7 +268,7 @@ export function CompanyRail() {
{/* Company list */} -
+
setSelectedCompanyId(company.id)} /> ))} @@ -250,7 +301,8 @@ export function CompanyRail() { diff --git a/ui/src/components/CompanySwitcher.tsx b/ui/src/components/CompanySwitcher.tsx index 3f68b9e8..aefe1020 100644 --- a/ui/src/components/CompanySwitcher.tsx +++ b/ui/src/components/CompanySwitcher.tsx @@ -1,5 +1,5 @@ import { ChevronsUpDown, Plus, Settings } from "lucide-react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { DropdownMenu, diff --git a/ui/src/components/CopyText.tsx b/ui/src/components/CopyText.tsx index 27812501..61a6b343 100644 --- a/ui/src/components/CopyText.tsx +++ b/ui/src/components/CopyText.tsx @@ -12,15 +12,21 @@ interface CopyTextProps { export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) { const [visible, setVisible] = useState(false); + const [label, setLabel] = useState(copiedLabel); const timerRef = useRef>(undefined); const triggerRef = useRef(null); - const handleClick = useCallback(() => { - navigator.clipboard.writeText(text); + const handleClick = useCallback(async () => { + try { + await navigator.clipboard.writeText(text); + setLabel(copiedLabel); + } catch { + setLabel("Copy failed"); + } clearTimeout(timerRef.current); setVisible(true); timerRef.current = setTimeout(() => setVisible(false), 1500); - }, [text]); + }, [copiedLabel, text]); return ( @@ -36,12 +42,14 @@ export function CopyText({ text, children, className, copiedLabel = "Copied!" }: {children ?? text} - {copiedLabel} + {label} ); diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index a5aeb9a5..4c375fbd 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { cn } from "../lib/utils"; interface EntityRowProps { diff --git a/ui/src/components/GoalProperties.tsx b/ui/src/components/GoalProperties.tsx index 46210930..ee8ce80b 100644 --- a/ui/src/components/GoalProperties.tsx +++ b/ui/src/components/GoalProperties.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import type { Goal } from "@paperclip/shared"; import { GOAL_STATUSES, GOAL_LEVELS } from "@paperclip/shared"; @@ -8,11 +8,10 @@ import { goalsApi } from "../api/goals"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { StatusBadge } from "./StatusBadge"; -import { formatDate } from "../lib/utils"; +import { formatDate, cn, agentUrl } from "../lib/utils"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; -import { cn } from "../lib/utils"; interface GoalPropertiesProps { goal: Goal; @@ -128,7 +127,7 @@ export function GoalProperties({ goal, onUpdate }: GoalPropertiesProps) { {ownerAgent ? ( {ownerAgent.name} diff --git a/ui/src/components/GoalTree.tsx b/ui/src/components/GoalTree.tsx index 88f86ebd..6bfd3643 100644 --- a/ui/src/components/GoalTree.tsx +++ b/ui/src/components/GoalTree.tsx @@ -1,5 +1,5 @@ import type { Goal } from "@paperclip/shared"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { StatusBadge } from "./StatusBadge"; import { ChevronRight } from "lucide-react"; import { cn } from "../lib/utils"; diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index 24d3cb62..6897f881 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -106,6 +106,7 @@ export const InlineEntitySelector = forwardRef { event.preventDefault(); @@ -157,7 +158,10 @@ export const InlineEntitySelector = forwardRef -
+
{filteredOptions.length === 0 ? (

{emptyMessage}

) : ( @@ -169,7 +173,7 @@ export const InlineEntitySelector = forwardRef setHighlightedIndex(index)} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 75e13bcf..23ddbb14 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import type { Issue } from "@paperclip/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { agentsApi } from "../api/agents"; @@ -12,7 +12,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; -import { formatDate, cn } from "../lib/utils"; +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"; @@ -175,6 +175,11 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp const project = orderedProjects.find((p) => p.id === id); return project?.name ?? id.slice(0, 8); }; + const projectLink = (id: string | null) => { + if (!id) return null; + const project = projects?.find((p) => p.id === id) ?? null; + return project ? projectUrl(project) : `/projects/${id}`; + }; const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) @@ -283,7 +288,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp } > - {createLabel.isPending ? "Creating..." : "Create label"} + {createLabel.isPending ? "Creating…" : "Create label"}
@@ -482,7 +487,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp popoverClassName="w-fit min-w-[11rem]" extra={issue.projectId ? ( e.stopPropagation()} > diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 5b47ed83..646cf6c5 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { DndContext, DragOverlay, diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index ff1f28bf..58546c99 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type UIEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Sun } from "lucide-react"; -import { Outlet } from "react-router-dom"; +import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { SidebarNavItem } from "./SidebarNavItem"; @@ -31,8 +31,11 @@ export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); const { panelContent, closePanel } = usePanel(); - const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany(); + const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { theme, toggleTheme } = useTheme(); + const { companyPrefix } = useParams<{ companyPrefix: string }>(); + const navigate = useNavigate(); + const location = useLocation(); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); @@ -52,6 +55,40 @@ export function Layout() { } }, [companies, companiesLoading, openOnboarding, health?.deploymentMode]); + useEffect(() => { + if (!companyPrefix || companiesLoading || companies.length === 0) return; + + const requestedPrefix = companyPrefix.toUpperCase(); + const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix); + + if (!matched) { + const fallback = + (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null) + ?? companies[0]!; + navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true }); + return; + } + + if (companyPrefix !== matched.issuePrefix) { + const suffix = location.pathname.replace(/^\/[^/]+/, ""); + navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true }); + return; + } + + if (selectedCompanyId !== matched.id) { + setSelectedCompanyId(matched.id, { source: "route_sync" }); + } + }, [ + companyPrefix, + companies, + companiesLoading, + location.pathname, + location.search, + navigate, + selectedCompanyId, + setSelectedCompanyId, + ]); + const togglePanel = useCallback(() => { if (panelContent) closePanel(); }, [panelContent, closePanel]); @@ -151,11 +188,19 @@ export function Layout() { return (
+ + Skip to Main Content + {/* Mobile backdrop */} {isMobile && sidebarOpen && ( -
setSidebarOpen(false)} + aria-label="Close sidebar" /> )} @@ -163,7 +208,7 @@ export function Layout() { {isMobile ? (
@@ -199,7 +244,7 @@ export function Layout() {
@@ -235,6 +280,8 @@ export function Layout() {
diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 11e1e5d5..8a12040f 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -1,14 +1,15 @@ import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { LiveEvent } from "@paperclip/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; import { getUIAdapter } from "../adapters"; import type { TranscriptEntry } from "../adapters"; import { queryKeys } from "../lib/queryKeys"; -import { cn, relativeTime } from "../lib/utils"; +import { cn, relativeTime, formatDateTime } from "../lib/utils"; import { ExternalLink, Square } from "lucide-react"; import { Identity } from "./Identity"; +import { StatusBadge } from "./StatusBadge"; interface LiveRunWidgetProps { issueId: string; @@ -311,55 +312,54 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { if (runs.length === 0 && feed.length === 0) return null; const recent = feed.slice(-25); - const headerRun = - runs[0] ?? - (() => { - const last = recent[recent.length - 1]; - if (!last) return null; - const meta = runMetaByIdRef.current.get(last.runId); - if (!meta) return null; - return { - id: last.runId, - agentId: meta.agentId, - }; - })(); return (
-
-
- {runs.length > 0 && ( - - - - - )} - - {runs.length > 0 ? `Live issue runs (${runs.length})` : "Recent run updates"} - -
- {headerRun && ( -
- {runs.length > 0 && ( - - )} - - Open run - - + {run.id.slice(0, 8)} + + +
+ + + Open run + + +
+
- )} -
+ )) + ) : ( +
+ Recent run updates +
+ )}
{recent.length === 0 && ( @@ -390,21 +390,6 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { ))}
- {runs.length > 1 && ( -
- {runs.map((run) => ( -
- - {run.id.slice(0, 8)} - - -
- ))} -
- )}
); } diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index e41391d6..5d40909d 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -10,9 +10,11 @@ import { type DragEvent, } from "react"; import { + CodeMirrorEditor, MDXEditor, codeBlockPlugin, codeMirrorPlugin, + type CodeBlockEditorDescriptor, type MDXEditorMethods, headingsPlugin, imagePlugin, @@ -90,6 +92,14 @@ const CODE_BLOCK_LANGUAGES: Record = { yml: "YAML", }; +const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = { + // Keep this lower than codeMirrorPlugin's descriptor priority so known languages + // still use the standard matching path; this catches malformed/unknown fences. + priority: 0, + match: () => true, + Editor: CodeMirrorEditor, +}; + function detectMention(container: HTMLElement): MentionState | null { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; @@ -247,7 +257,10 @@ export const MarkdownEditor = forwardRef linkPlugin(), linkDialogPlugin(), thematicBreakPlugin(), - codeBlockPlugin(), + codeBlockPlugin({ + defaultCodeBlockLanguage: "txt", + codeBlockEditorDescriptors: [FALLBACK_CODE_BLOCK_DESCRIPTOR], + }), codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }), markdownShortcutPlugin(), ]; diff --git a/ui/src/components/MetricCard.tsx b/ui/src/components/MetricCard.tsx index 470ceab5..b954367d 100644 --- a/ui/src/components/MetricCard.tsx +++ b/ui/src/components/MetricCard.tsx @@ -1,6 +1,6 @@ import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; interface MetricCardProps { icon: LucideIcon; diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index 61091d30..e9e5c150 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { NavLink, useLocation } from "react-router-dom"; +import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { House, @@ -75,7 +75,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { {items.map((item) => { if (item.type === "action") { const Icon = item.icon; - const active = location.pathname.startsWith("/issues/new"); + const active = /\/issues\/new(?:\/|$)/.test(location.pathname); return (
diff --git a/ui/src/components/NewGoalDialog.tsx b/ui/src/components/NewGoalDialog.tsx index 2dc5453d..41e711fb 100644 --- a/ui/src/components/NewGoalDialog.tsx +++ b/ui/src/components/NewGoalDialog.tsx @@ -273,7 +273,7 @@ export function NewGoalDialog() { disabled={!title.trim() || createGoal.isPending} onClick={handleSubmit} > - {createGoal.isPending ? "Creating..." : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"} + {createGoal.isPending ? "Creating…" : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"}
diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index b163f3dc..4db14c26 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -468,7 +468,7 @@ export function NewProjectDialog() { disabled={!name.trim() || createProject.isPending} onClick={handleSubmit} > - {createProject.isPending ? "Creating..." : "Create project"} + {createProject.isPending ? "Creating…" : "Create project"}
diff --git a/ui/src/components/PageSkeleton.tsx b/ui/src/components/PageSkeleton.tsx index 05366944..cf2e2abd 100644 --- a/ui/src/components/PageSkeleton.tsx +++ b/ui/src/components/PageSkeleton.tsx @@ -1,26 +1,160 @@ import { Skeleton } from "@/components/ui/skeleton"; interface PageSkeletonProps { - variant?: "list" | "detail"; + variant?: + | "list" + | "issues-list" + | "detail" + | "dashboard" + | "approvals" + | "costs" + | "inbox" + | "org-chart"; } export function PageSkeleton({ variant = "list" }: PageSkeletonProps) { + if (variant === "dashboard") { + return ( +
+ + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+ + +
+
+ ); + } + + if (variant === "approvals") { + return ( +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (variant === "costs") { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ + + +
+ + +
+
+ ); + } + + if (variant === "inbox") { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 3 }).map((_, section) => ( +
+ +
+ {Array.from({ length: 3 }).map((_, row) => ( + + ))} +
+
+ ))} +
+
+ ); + } + + if (variant === "org-chart") { + return ( +
+ +
+ ); + } + if (variant === "detail") { return (
-
- - - +
+ +
+ + + +
+
+
+
- - - +
+ + + +
+ + +
+
+ ); + } + + if (variant === "issues-list") { + return ( +
+
+ +
+ + + + +
+
+ +
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
); @@ -28,14 +162,17 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) { return (
-
- - +
+ +
+ + +
- +
- {Array.from({ length: 8 }).map((_, i) => ( - + {Array.from({ length: 7 }).map((_, i) => ( + ))}
diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 1d126016..d4e8ca0e 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Project } from "@paperclip/shared"; import { StatusBadge } from "./StatusBadge"; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index d6e89b42..c1b57f7e 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -21,7 +21,6 @@ import { sidebarBadgesApi } from "../api/sidebarBadges"; import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; export function Sidebar() { const { openNewIssue } = useDialog(); @@ -66,45 +65,43 @@ export function Sidebar() {
- - ); } diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 8687add1..7b469771 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { NavLink, useLocation } from "react-router-dom"; +import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; @@ -7,7 +7,7 @@ import { useSidebar } from "../context/SidebarContext"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; -import { cn } from "../lib/utils"; +import { cn, agentRouteRef, agentUrl } from "../lib/utils"; import { AgentIcon } from "./AgentIconPicker"; import { Collapsible, @@ -71,7 +71,7 @@ export function SidebarAgents() { return sortByHierarchy(filtered); }, [agents]); - const agentMatch = location.pathname.match(/^\/agents\/([^/]+)/); + const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)/); const activeAgentId = agentMatch?.[1] ?? null; return ( @@ -99,13 +99,13 @@ export function SidebarAgents() { return ( { if (isMobile) setSidebarOpen(false); }} className={cn( "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", - activeAgentId === agent.id + activeAgentId === agentRouteRef(agent) ? "bg-accent text-foreground" : "text-foreground/80 hover:bg-accent/50 hover:text-foreground" )} diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index cd42c86c..6d3f4995 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -1,4 +1,4 @@ -import { NavLink } from "react-router-dom"; +import { NavLink } from "@/lib/router"; import { cn } from "../lib/utils"; import { useSidebar } from "../context/SidebarContext"; import type { LucideIcon } from "lucide-react"; diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index 0ff66459..f32e6b40 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from "react"; -import { NavLink, useLocation } from "react-router-dom"; +import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight, Plus } from "lucide-react"; import { @@ -18,7 +18,7 @@ import { useSidebar } from "../context/SidebarContext"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { queryKeys } from "../lib/queryKeys"; -import { cn } from "../lib/utils"; +import { cn, projectRouteRef } from "../lib/utils"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { Collapsible, @@ -28,12 +28,12 @@ import { import type { Project } from "@paperclip/shared"; function SortableProjectItem({ - activeProjectId, + activeProjectRef, isMobile, project, setSidebarOpen, }: { - activeProjectId: string | null; + activeProjectRef: string | null; isMobile: boolean; project: Project; setSidebarOpen: (open: boolean) => void; @@ -47,6 +47,8 @@ function SortableProjectItem({ isDragging, } = useSortable({ id: project.id }); + const routeRef = projectRouteRef(project); + return (
{ if (isMobile) setSidebarOpen(false); }} className={cn( "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", - activeProjectId === project.id + activeProjectRef === routeRef || activeProjectRef === project.id ? "bg-accent text-foreground" : "text-foreground/80 hover:bg-accent/50 hover:text-foreground", )} @@ -110,8 +112,8 @@ export function SidebarProjects() { userId: currentUserId, }); - const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/); - const activeProjectId = projectMatch?.[1] ?? null; + const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/); + const activeProjectRef = projectMatch?.[1] ?? null; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, @@ -175,7 +177,7 @@ export function SidebarProjects() { {orderedProjects.map((project: Project) => ( = { dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", search: "Enable Codex web search capability during runs.", - bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.", maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.", command: "The command to execute (e.g. node, python).", - localCommand: "Override the local CLI command (e.g. claude, /usr/local/bin/claude, codex).", + localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).", args: "Command-line arguments, comma-separated.", extraArgs: "Extra CLI arguments for local adapters, comma-separated.", envVars: "Environment variables injected into the adapter process. Use plain values or secret references.", @@ -372,3 +380,87 @@ export function DraftNumberInput({ /> ); } + +/** + * "Choose" button that opens a dialog explaining the user must manually + * type the path due to browser security limitations. + */ +export function ChoosePathButton() { + const [open, setOpen] = useState(false); + return ( + <> + + + + + Specify path manually + + Browser security blocks apps from reading full local paths via a file picker. + Copy the absolute path and paste it into the input. + + +
+
+

macOS (Finder)

+
    +
  1. Find the folder in Finder.
  2. +
  3. Hold Option and right-click the folder.
  4. +
  5. Click "Copy <folder name> as Pathname".
  6. +
  7. Paste the result into the path input.
  8. +
+

+ /Users/yourname/Documents/project +

+
+
+

Windows (File Explorer)

+
    +
  1. Find the folder in File Explorer.
  2. +
  3. Hold Shift and right-click the folder.
  4. +
  5. Click "Copy as path".
  6. +
  7. Paste the result into the path input.
  8. +
+

+ C:\Users\yourname\Documents\project +

+
+
+

Terminal fallback (macOS/Linux)

+
    +
  1. Run cd /path/to/folder.
  2. +
  3. Run pwd.
  4. +
  5. Copy the output and paste it into the path input.
  6. +
+
+
+ + + +
+
+ + ); +} + +/** + * Label + input rendered on the same line (inline layout for compact fields). + */ +export function InlineField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+
+ + {hint && } +
+
{children}
+
+ ); +} diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index b5ea4abd..e9933291 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,opacity] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { @@ -21,13 +21,13 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", + default: "h-10 px-4 py-2 has-[>svg]:px-3", xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", + icon: "size-10", "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", + "icon-sm": "size-9", "icon-lg": "size-10", }, }, diff --git a/ui/src/components/ui/skeleton.tsx b/ui/src/components/ui/skeleton.tsx index 32ea0ef7..ef338405 100644 --- a/ui/src/components/ui/skeleton.tsx +++ b/ui/src/components/ui/skeleton.tsx @@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
) diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx index b5160bfe..2c5f7008 100644 --- a/ui/src/components/ui/tabs.tsx +++ b/ui/src/components/ui/tabs.tsx @@ -62,7 +62,7 @@ function TabsTrigger({ void; + setSelectedCompanyId: (companyId: string, options?: CompanySelectionOptions) => void; reloadCompanies: () => Promise; createCompany: (data: { name: string; @@ -34,24 +38,8 @@ const CompanyContext = createContext(null); export function CompanyProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); - const [selectedCompanyId, setSelectedCompanyIdState] = useState( - () => { - // Check URL param first (supports "open in new tab" from company rail) - const urlParams = new URLSearchParams(window.location.search); - const companyParam = urlParams.get("company"); - if (companyParam) { - localStorage.setItem(STORAGE_KEY, companyParam); - // Clean up the URL param - urlParams.delete("company"); - const newSearch = urlParams.toString(); - const newUrl = - window.location.pathname + (newSearch ? `?${newSearch}` : ""); - window.history.replaceState({}, "", newUrl); - return companyParam; - } - return localStorage.getItem(STORAGE_KEY); - } - ); + const [selectionSource, setSelectionSource] = useState("bootstrap"); + const [selectedCompanyId, setSelectedCompanyIdState] = useState(() => localStorage.getItem(STORAGE_KEY)); const { data: companies = [], isLoading, error } = useQuery({ queryKey: queryKeys.companies.all, @@ -83,11 +71,13 @@ export function CompanyProvider({ children }: { children: ReactNode }) { const next = selectableCompanies[0]!.id; setSelectedCompanyIdState(next); + setSelectionSource("bootstrap"); localStorage.setItem(STORAGE_KEY, next); }, [companies, selectedCompanyId, sidebarCompanies]); - const setSelectedCompanyId = useCallback((companyId: string) => { + const setSelectedCompanyId = useCallback((companyId: string, options?: CompanySelectionOptions) => { setSelectedCompanyIdState(companyId); + setSelectionSource(options?.source ?? "manual"); localStorage.setItem(STORAGE_KEY, companyId); }, []); @@ -121,6 +111,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) { companies, selectedCompanyId, selectedCompany, + selectionSource, loading: isLoading, error: error as Error | null, setSelectedCompanyId, @@ -131,6 +122,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) { companies, selectedCompanyId, selectedCompany, + selectionSource, isLoading, error, setSelectedCompanyId, diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 433eed03..6bcbab0c 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -125,7 +125,7 @@ function resolveIssueToastContext( } const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); -const AGENT_TOAST_STATUSES = new Set(["running", "idle", "error"]); +const AGENT_TOAST_STATUSES = new Set(["running", "error"]); const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]); function describeIssueUpdate(details: Record | null): string | null { @@ -178,6 +178,10 @@ function buildActivityToast( } if (action === "issue.updated") { + if (details?.reopened === true && readString(details.source) === "comment") { + // Reopen-via-comment emits a paired comment event; show one combined toast on the comment event. + return null; + } const changeDesc = describeIssueUpdate(details); const body = changeDesc ? issue.title @@ -197,13 +201,26 @@ function buildActivityToast( const commentId = readString(details?.commentId); const bodySnippet = readString(details?.bodySnippet); + const reopened = details?.reopened === true; + const reopenedFrom = readString(details?.reopenedFrom); + const reopenedLabel = reopened + ? reopenedFrom + ? `reopened from ${reopenedFrom.replace(/_/g, " ")}` + : "reopened" + : null; + const title = reopened ? `${actor} reopened and commented on ${issue.ref}` : `${actor} commented on ${issue.ref}`; + const body = bodySnippet + ? reopenedLabel + ? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}` + : bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ") + : reopenedLabel + ? issue.title + ? `${reopenedLabel} - ${issue.title}` + : reopenedLabel + : issue.title ?? undefined; return { - title: `${actor} commented on ${issue.ref}`, - body: bodySnippet - ? truncate(bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " "), 96) - : issue.title - ? truncate(issue.title, 96) - : undefined, + title, + body: body ? truncate(body, 96) : undefined, tone: "info", action: { label: `View ${issue.ref}`, href: issue.href }, dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`, @@ -220,14 +237,12 @@ function buildAgentStatusToast( const status = readString(payload.status); if (!agentId || !status || !AGENT_TOAST_STATUSES.has(status)) return null; - const tone = status === "error" ? "error" : status === "idle" ? "success" : "info"; + const tone = status === "error" ? "error" : "info"; const name = nameOf(agentId) ?? `Agent ${shortId(agentId)}`; const title = status === "running" ? `${name} started` - : status === "idle" - ? `${name} is idle` - : `${name} errored`; + : `${name} errored`; const agents = queryClient.getQueryData(queryKeys.agents.list(companyId)); const agent = agents?.find((a) => a.id === agentId); diff --git a/ui/src/hooks/useCompanyPageMemory.ts b/ui/src/hooks/useCompanyPageMemory.ts index e28a636f..d427e587 100644 --- a/ui/src/hooks/useCompanyPageMemory.ts +++ b/ui/src/hooks/useCompanyPageMemory.ts @@ -1,8 +1,10 @@ import { useEffect, useRef } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; +import { toCompanyRelativePath } from "../lib/company-routes"; const STORAGE_KEY = "paperclip.companyPaths"; +const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]); function getCompanyPaths(): Record { try { @@ -20,12 +22,21 @@ function saveCompanyPath(companyId: string, path: string) { localStorage.setItem(STORAGE_KEY, JSON.stringify(paths)); } +function isRememberableCompanyPath(path: string): boolean { + const pathname = path.split("?")[0] ?? ""; + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 0) return true; + const [root] = segments; + if (GLOBAL_SEGMENTS.has(root!)) return false; + return true; +} + /** * Remembers the last visited page per company and navigates to it on company switch. * Falls back to /dashboard if no page was previously visited for a company. */ export function useCompanyPageMemory() { - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, selectedCompany, selectionSource } = useCompany(); const location = useLocation(); const navigate = useNavigate(); const prevCompanyId = useRef(selectedCompanyId); @@ -36,8 +47,9 @@ export function useCompanyPageMemory() { const fullPath = location.pathname + location.search; useEffect(() => { const companyId = prevCompanyId.current; - if (companyId) { - saveCompanyPath(companyId, fullPath); + const relativePath = toCompanyRelativePath(fullPath); + if (companyId && isRememberableCompanyPath(relativePath)) { + saveCompanyPath(companyId, relativePath); } }, [fullPath]); @@ -49,10 +61,14 @@ export function useCompanyPageMemory() { prevCompanyId.current !== null && selectedCompanyId !== prevCompanyId.current ) { - const paths = getCompanyPaths(); - const savedPath = paths[selectedCompanyId]; - navigate(savedPath || "/dashboard", { replace: true }); + if (selectionSource !== "route_sync" && selectedCompany) { + const paths = getCompanyPaths(); + const savedPath = paths[selectedCompanyId]; + const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard"; + const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard"; + navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true }); + } } prevCompanyId.current = selectedCompanyId; - }, [selectedCompanyId, navigate]); + }, [selectedCompany, selectedCompanyId, selectionSource, navigate]); } diff --git a/ui/src/index.css b/ui/src/index.css index a9a8afca..76e4e8df 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -120,12 +120,18 @@ } html { height: 100%; + -webkit-tap-highlight-color: color-mix(in oklab, var(--foreground) 20%, transparent); } body { @apply bg-background text-foreground; height: 100%; overflow: hidden; } + h1, + h2, + h3 { + text-wrap: balance; + } /* Prevent double-tap-to-zoom on interactive elements for mobile */ a, button, @@ -138,6 +144,17 @@ } } +@media (pointer: coarse) { + button, + [role="button"], + input, + select, + textarea, + [data-slot="select-trigger"] { + min-height: 44px; + } +} + /* Dark mode scrollbars */ .dark { color-scheme: dark; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index c83ed1d2..bc837c31 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,5 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import { deriveAgentUrlKey, deriveProjectUrlKey } from "@paperclip/shared"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -51,3 +52,23 @@ export function formatTokens(n: number): string { export function issueUrl(issue: { id: string; identifier?: string | null }): string { return `/issues/${issue.identifier ?? issue.id}`; } + +/** Build an agent route URL using the short URL key when available. */ +export function agentRouteRef(agent: { id: string; urlKey?: string | null; name?: string | null }): string { + return agent.urlKey ?? deriveAgentUrlKey(agent.name, agent.id); +} + +/** Build an agent URL using the short URL key when available. */ +export function agentUrl(agent: { id: string; urlKey?: string | null; name?: string | null }): string { + return `/agents/${agentRouteRef(agent)}`; +} + +/** Build a project route reference using the short URL key when available. */ +export function projectRouteRef(project: { id: string; urlKey?: string | null; name?: string | null }): string { + return project.urlKey ?? deriveProjectUrlKey(project.name, project.id); +} + +/** Build a project URL using the short URL key when available. */ +export function projectUrl(project: { id: string; urlKey?: string | null; name?: string | null }): string { + return `/projects/${projectRouteRef(project)}`; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 8927cf86..ba8a4d69 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; +import { BrowserRouter } from "@/lib/router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "./App"; import { CompanyProvider } from "./context/CompanyContext"; diff --git a/ui/src/pages/Activity.tsx b/ui/src/pages/Activity.tsx index 17b2829f..185d2f3c 100644 --- a/ui/src/pages/Activity.tsx +++ b/ui/src/pages/Activity.tsx @@ -10,6 +10,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { ActivityRow } from "../components/ActivityRow"; +import { PageSkeleton } from "../components/PageSkeleton"; import { Select, SelectContent, @@ -84,6 +85,10 @@ export function Activity() { return ; } + if (isLoading) { + return ; + } + const filtered = data && filter !== "all" ? data.filter((e) => e.entityType === filter) @@ -111,7 +116,6 @@ export function Activity() {
- {isLoading &&

Loading...

} {error &&

{error.message}

} {filtered && filtered.length === 0 && ( diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index e8d7dd46..327cd1aa 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom"; +import { useParams, useNavigate, Link, useBeforeUnload } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; @@ -22,6 +22,7 @@ import { MarkdownBody } from "../components/MarkdownBody"; import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; +import { PageSkeleton } from "../components/PageSkeleton"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; @@ -54,7 +55,8 @@ import { } from "lucide-react"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; -import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared"; +import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState } from "@paperclip/shared"; +import { agentRouteRef } from "../lib/utils"; const runStatusIcons: Record = { succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" }, @@ -223,8 +225,13 @@ function asNonEmptyString(value: unknown): string | null { } export function AgentDetail() { - const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>(); - const { selectedCompanyId } = useCompany(); + const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{ + companyPrefix?: string; + agentId: string; + tab?: string; + runId?: string; + }>(); + const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { closePanel } = usePanel(); const { openNewIssue } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -238,68 +245,101 @@ export function AgentDetail() { const saveConfigActionRef = useRef<(() => void) | null>(null); const cancelConfigActionRef = useRef<(() => void) | null>(null); const { isMobile } = useSidebar(); + const routeAgentRef = agentId ?? ""; + 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 canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId)); const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); const { data: agent, isLoading, error } = useQuery({ - queryKey: queryKeys.agents.detail(agentId!), - queryFn: () => agentsApi.get(agentId!), - enabled: !!agentId, + queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null], + queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId), + enabled: canFetchAgent, }); + const resolvedCompanyId = agent?.companyId ?? selectedCompanyId; + const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; + const agentLookupRef = agent?.id ?? routeAgentRef; const { data: runtimeState } = useQuery({ - queryKey: queryKeys.agents.runtimeState(agentId!), - queryFn: () => agentsApi.runtimeState(agentId!), - enabled: !!agentId, + queryKey: queryKeys.agents.runtimeState(agentLookupRef), + queryFn: () => agentsApi.runtimeState(agentLookupRef, resolvedCompanyId ?? undefined), + enabled: Boolean(agentLookupRef), }); const { data: heartbeats } = useQuery({ - queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId), - queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId), - enabled: !!selectedCompanyId && !!agentId, + queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined), + queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined), + enabled: !!resolvedCompanyId && !!agent?.id, }); const { data: allIssues } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, + queryKey: queryKeys.issues.list(resolvedCompanyId!), + queryFn: () => issuesApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId, }); const { data: allAgents } = useQuery({ - queryKey: queryKeys.agents.list(selectedCompanyId!), - queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, + queryKey: queryKeys.agents.list(resolvedCompanyId!), + queryFn: () => agentsApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId, }); - const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId); + const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agent?.id); const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); - const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated"); + const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated"); const mobileLiveRun = useMemo( () => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null, [heartbeats], ); + useEffect(() => { + if (!agent) return; + if (routeAgentRef === canonicalAgentRef) return; + if (urlRunId) { + navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true }); + return; + } + if (urlTab) { + navigate(`/agents/${canonicalAgentRef}/${urlTab}`, { replace: true }); + return; + } + navigate(`/agents/${canonicalAgentRef}`, { replace: true }); + }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, navigate]); + + useEffect(() => { + if (!agent?.companyId || agent.companyId === selectedCompanyId) return; + setSelectedCompanyId(agent.companyId, { source: "route_sync" }); + }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]); + const agentAction = useMutation({ mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => { - if (!agentId) return Promise.reject(new Error("No agent ID")); + if (!agentLookupRef) return Promise.reject(new Error("No agent reference")); switch (action) { - case "invoke": return agentsApi.invoke(agentId); - case "pause": return agentsApi.pause(agentId); - case "resume": return agentsApi.resume(agentId); - case "terminate": return agentsApi.terminate(agentId); + case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined); + case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined); + case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined); + case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined); } }, onSuccess: (data, action) => { setActionError(null); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) }); - if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(selectedCompanyId, agentId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) }); + if (resolvedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); + if (agent?.id) { + queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) }); + } } if (action === "invoke" && data && typeof data === "object" && "id" in data) { - navigate(`/agents/${agentId}/runs/${(data as HeartbeatRun).id}`); + navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`); } }, onError: (err) => { @@ -308,21 +348,23 @@ export function AgentDetail() { }); const updateIcon = useMutation({ - mutationFn: (icon: string) => agentsApi.update(agentId!, { icon }), + mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); - if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + if (resolvedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); } }, }); const resetTaskSession = useMutation({ - mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey), + mutationFn: (taskKey: string | null) => + agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined), onSuccess: () => { setActionError(null); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) }); }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to reset session"); @@ -331,12 +373,13 @@ export function AgentDetail() { const updatePermissions = useMutation({ mutationFn: (canCreateAgents: boolean) => - agentsApi.updatePermissions(agentId!, { canCreateAgents }), + agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined), onSuccess: () => { setActionError(null); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); - if (selectedCompanyId) { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + if (resolvedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); } }, onError: (err) => { @@ -348,13 +391,13 @@ export function AgentDetail() { const crumbs: { label: string; href?: string }[] = [ { label: "Agents", href: "/agents" }, ]; - const agentName = agent?.name ?? agentId ?? "Agent"; + const agentName = agent?.name ?? routeAgentRef ?? "Agent"; if (activeView === "overview" && !urlRunId) { crumbs.push({ label: agentName }); } else { - crumbs.push({ label: agentName, href: `/agents/${agentId}` }); + crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}` }); if (urlRunId) { - crumbs.push({ label: "Runs", href: `/agents/${agentId}/runs` }); + crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` }); crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); } else if (activeView === "configure") { crumbs.push({ label: "Configure" }); @@ -363,7 +406,7 @@ export function AgentDetail() { } } setBreadcrumbs(crumbs); - }, [setBreadcrumbs, agent, agentId, activeView, urlRunId]); + }, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]); useEffect(() => { closePanel(); @@ -378,7 +421,7 @@ export function AgentDetail() { }, [configDirty]), ); - if (isLoading) return

Loading...

; + if (isLoading) return ; if (error) return

{error.message}

; if (!agent) return null; const isPendingApproval = agent.status === "pending_approval"; @@ -409,7 +452,7 @@ export function AgentDetail() { +
@@ -558,7 +611,7 @@ export function AgentDetail() { onClick={() => saveConfigActionRef.current?.()} disabled={configSaving} > - {configSaving ? "Saving..." : "Save"} + {configSaving ? "Saving…" : "Save"}
@@ -573,14 +626,16 @@ export function AgentDetail() { runtimeState={runtimeState} reportsToAgent={reportsToAgent ?? null} directReports={directReports} - agentId={agentId!} + agentId={agent.id} + agentRouteId={canonicalAgentRef} /> )} {activeView === "configure" && ( @@ -631,7 +687,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin return (
-
+

{isLive && ( @@ -649,10 +705,13 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin

-
+
@@ -674,7 +733,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin {summary}
)} -
+
); } @@ -689,6 +748,7 @@ function AgentOverview({ reportsToAgent, directReports, agentId, + agentRouteId, }: { agent: Agent; runs: HeartbeatRun[]; @@ -697,11 +757,12 @@ function AgentOverview({ reportsToAgent: Agent | null; directReports: Agent[]; agentId: string; + agentRouteId: string; }) { return (
{/* Latest Run */} - + {/* Charts */}
@@ -758,7 +819,7 @@ function AgentOverview({ {/* Configuration Summary */} @@ -772,12 +833,12 @@ function AgentOverview({ function ConfigSummary({ agent, - agentId, + agentRouteId, reportsToAgent, directReports, }: { agent: Agent; - agentId: string; + agentRouteId: string; reportsToAgent: Agent | null; directReports: Agent[]; }) { @@ -789,7 +850,7 @@ function ConfigSummary({

Configuration

@@ -835,7 +896,7 @@ function ConfigSummary({ {reportsToAgent ? ( @@ -852,7 +913,7 @@ function ConfigSummary({ {directReports.map((r) => ( @@ -966,6 +1027,7 @@ function CostsSection({ function AgentConfigurePage({ agent, agentId, + companyId, onDirtyChange, onSaveActionChange, onCancelActionChange, @@ -974,6 +1036,7 @@ function AgentConfigurePage({ }: { agent: Agent; agentId: string; + companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; @@ -985,11 +1048,11 @@ function AgentConfigurePage({ const { data: configRevisions } = useQuery({ queryKey: queryKeys.agents.configRevisions(agent.id), - queryFn: () => agentsApi.listConfigRevisions(agent.id), + queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId), }); const rollbackConfig = useMutation({ - mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId), + mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); @@ -1005,10 +1068,11 @@ function AgentConfigurePage({ onCancelActionChange={onCancelActionChange} onSavingChange={onSavingChange} updatePermissions={updatePermissions} + companyId={companyId} />

API Keys

- +
{/* Configuration Revisions — collapsible at the bottom */} @@ -1069,6 +1133,7 @@ function AgentConfigurePage({ function ConfigurationTab({ agent, + companyId, onDirtyChange, onSaveActionChange, onCancelActionChange, @@ -1076,6 +1141,7 @@ function ConfigurationTab({ updatePermissions, }: { agent: Agent; + companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; @@ -1090,7 +1156,7 @@ function ConfigurationTab({ }); const updateAgent = useMutation({ - mutationFn: (data: Record) => agentsApi.update(agent.id, data), + mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); @@ -1190,7 +1256,21 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect ); } -function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null; adapterType: string }) { +function RunsTab({ + runs, + companyId, + agentId, + agentRouteId, + selectedRunId, + adapterType, +}: { + runs: HeartbeatRun[]; + companyId: string; + agentId: string; + agentRouteId: string; + selectedRunId: string | null; + adapterType: string; +}) { const { isMobile } = useSidebar(); if (runs.length === 0) { @@ -1212,20 +1292,20 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run return (
Back to runs - +
); } return (
{sorted.map((run) => ( - + ))}
); @@ -1241,7 +1321,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run )}>
{sorted.map((run) => ( - + ))}
@@ -1249,7 +1329,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run {/* Right: run detail — natural height, page scrolls */} {selectedRun && (
- +
)}
@@ -1258,7 +1338,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run /* ---- Run Detail (expanded) ---- */ -function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) { +function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) { const queryClient = useQueryClient(); const navigate = useNavigate(); const metrics = runMetrics(run); @@ -1299,7 +1379,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin triggerDetail: "manual", reason: "resume_process_lost_run", payload: resumePayload, - }); + }, run.companyId); if (!("id" in result)) { throw new Error("Resume request was skipped because the agent is not currently invokable."); } @@ -1307,7 +1387,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin }, onSuccess: (resumedRun) => { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); - navigate(`/agents/${run.agentId}/runs/${resumedRun.id}`); + navigate(`/agents/${agentRouteId}/runs/${resumedRun.id}`); }, }); @@ -1323,7 +1403,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const clearSessionsForTouchedIssues = useMutation({ mutationFn: async () => { if (touchedIssueIds.length === 0) return 0; - await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId))); + await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId, run.companyId))); return touchedIssueIds.length; }, onSuccess: () => { @@ -1334,7 +1414,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin }); const runClaudeLogin = useMutation({ - mutationFn: () => agentsApi.loginWithClaude(run.agentId), + mutationFn: () => agentsApi.loginWithClaude(run.agentId, run.companyId), onSuccess: (data) => { setClaudeLoginResult(data); }, @@ -1386,7 +1466,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin onClick={() => cancelRun.mutate()} disabled={cancelRun.isPending} > - {cancelRun.isPending ? "Cancelling..." : "Cancel"} + {cancelRun.isPending ? "Cancelling…" : "Cancel"} )} {canResumeLostRun && ( @@ -1398,7 +1478,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin disabled={resumeRun.isPending} > - {resumeRun.isPending ? "Resuming..." : "Resume"} + {resumeRun.isPending ? "Resuming…" : "Resume"} )}
@@ -1898,6 +1978,20 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
)} + {Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && ( +
+
Command notes
+
    + {adapterInvokePayload.commandNotes + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((note, idx) => ( +
  • + {note} +
  • + ))} +
+
+ )} {adapterInvokePayload.prompt !== undefined && (
Prompt
@@ -2147,7 +2241,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin /* ---- Keys Tab ---- */ -function KeysTab({ agentId }: { agentId: string }) { +function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) { const queryClient = useQueryClient(); const [newKeyName, setNewKeyName] = useState(""); const [newToken, setNewToken] = useState(null); @@ -2156,11 +2250,11 @@ function KeysTab({ agentId }: { agentId: string }) { const { data: keys, isLoading } = useQuery({ queryKey: queryKeys.agents.keys(agentId), - queryFn: () => agentsApi.listKeys(agentId), + queryFn: () => agentsApi.listKeys(agentId, companyId), }); const createKey = useMutation({ - mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default"), + mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default", companyId), onSuccess: (data) => { setNewToken(data.token); setTokenVisible(true); @@ -2170,7 +2264,7 @@ function KeysTab({ agentId }: { agentId: string }) { }); const revokeKey = useMutation({ - mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId), + mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId, companyId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) }); }, diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index ad75ab95..89e3fdbd 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from "react"; -import { Link, useNavigate, useLocation } from "react-router-dom"; +import { Link, useNavigate, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; @@ -12,7 +12,8 @@ import { StatusBadge } from "../components/StatusBadge"; import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors"; import { EntityRow } from "../components/EntityRow"; import { EmptyState } from "../components/EmptyState"; -import { relativeTime, cn } from "../lib/utils"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; import { PageTabBar } from "../components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -121,6 +122,10 @@ export function Agents() { return ; } + if (isLoading) { + return ; + } + const filtered = filterAgents(agents ?? [], tab, showTerminated); const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated); @@ -204,7 +209,6 @@ export function Agents() {

{filtered.length} agent{filtered.length !== 1 ? "s" : ""}

)} - {isLoading &&

Loading...

} {error &&

{error.message}

} {agents && agents.length === 0 && ( @@ -225,7 +229,7 @@ export function Agents() { key={agent.id} title={agent.name} subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`} - to={`/agents/${agent.id}`} + to={agentUrl(agent)} leading={ {liveRunByAgent.has(agent.id) ? ( @@ -249,7 +253,7 @@ export function Agents() {
{liveRunByAgent.has(agent.id) && ( @@ -320,7 +324,7 @@ function OrgTreeNode({ return (
@@ -337,7 +341,7 @@ function OrgTreeNode({ {liveRunByAgent.has(node.id) ? ( @@ -348,7 +352,7 @@ function OrgTreeNode({
{liveRunByAgent.has(node.id) && ( @@ -381,17 +385,17 @@ function OrgTreeNode({ } function LiveRunIndicator({ - agentId, + agentRef, runId, liveCount, }: { - agentId: string; + agentRef: string; runId: string; liveCount: number; }) { return ( e.stopPropagation()} > diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx index 49420373..413ee660 100644 --- a/ui/src/pages/ApprovalDetail.tsx +++ b/ui/src/pages/ApprovalDetail.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; import { agentsApi } from "../api/agents"; @@ -9,6 +9,7 @@ import { queryKeys } from "../lib/queryKeys"; import { StatusBadge } from "../components/StatusBadge"; import { Identity } from "../components/Identity"; import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload"; +import { PageSkeleton } from "../components/PageSkeleton"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react"; @@ -17,7 +18,7 @@ import { MarkdownBody } from "../components/MarkdownBody"; export function ApprovalDetail() { const { approvalId } = useParams<{ approvalId: string }>(); - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -31,6 +32,7 @@ export function ApprovalDetail() { queryFn: () => approvalsApi.get(approvalId!), enabled: !!approvalId, }); + const resolvedCompanyId = approval?.companyId ?? selectedCompanyId; const { data: comments } = useQuery({ queryKey: queryKeys.approvals.comments(approvalId!), @@ -45,11 +47,16 @@ export function ApprovalDetail() { }); const { data: agents } = useQuery({ - queryKey: queryKeys.agents.list(approval?.companyId ?? selectedCompanyId ?? ""), - queryFn: () => agentsApi.list(approval?.companyId ?? selectedCompanyId ?? ""), - enabled: !!(approval?.companyId ?? selectedCompanyId), + queryKey: queryKeys.agents.list(resolvedCompanyId ?? ""), + queryFn: () => agentsApi.list(resolvedCompanyId ?? ""), + enabled: !!resolvedCompanyId, }); + useEffect(() => { + if (!approval?.companyId || approval.companyId === selectedCompanyId) return; + setSelectedCompanyId(approval.companyId, { source: "route_sync" }); + }, [approval?.companyId, selectedCompanyId, setSelectedCompanyId]); + const agentNameById = useMemo(() => { const map = new Map(); for (const agent of agents ?? []) map.set(agent.id, agent.name); @@ -134,7 +141,7 @@ export function ApprovalDetail() { onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"), }); - if (isLoading) return

Loading...

; + if (isLoading) return ; if (!approval) return

Approval not found.

; const payload = approval.payload as Record; @@ -346,7 +353,7 @@ export function ApprovalDetail() { onClick={() => addCommentMutation.mutate()} disabled={!commentBody.trim() || addCommentMutation.isPending} > - {addCommentMutation.isPending ? "Posting..." : "Post comment"} + {addCommentMutation.isPending ? "Posting…" : "Post comment"}
diff --git a/ui/src/pages/Approvals.tsx b/ui/src/pages/Approvals.tsx index 7fdd6ec3..24c8da0a 100644 --- a/ui/src/pages/Approvals.tsx +++ b/ui/src/pages/Approvals.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate, useLocation } from "react-router-dom"; +import { useNavigate, useLocation } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; import { agentsApi } from "../api/agents"; @@ -11,6 +11,7 @@ import { PageTabBar } from "../components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; import { ShieldCheck } from "lucide-react"; import { ApprovalCard } from "../components/ApprovalCard"; +import { PageSkeleton } from "../components/PageSkeleton"; type StatusFilter = "pending" | "all"; @@ -77,6 +78,10 @@ export function Approvals() { return

Select a company first.

; } + if (isLoading) { + return ; + } + return (
@@ -95,11 +100,10 @@ export function Approvals() {
- {isLoading &&

Loading...

} {error &&

{error.message}

} {actionError &&

{actionError}

} - {!isLoading && filtered.length === 0 && ( + {filtered.length === 0 && (

diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx index 1ec01843..e562c7e8 100644 --- a/ui/src/pages/Auth.tsx +++ b/ui/src/pages/Auth.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useNavigate, useSearchParams } from "@/lib/router"; import { authApi } from "../api/auth"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; +import { AsciiArtAnimation } from "@/components/AsciiArtAnimation"; +import { Sparkles } from "lucide-react"; type AuthMode = "sign_in" | "sign_up"; @@ -59,83 +61,102 @@ export function AuthPage() { (mode === "sign_in" || name.trim().length > 0); if (isSessionLoading) { - return

Loading...
; + return ( +
+

Loading…

+
+ ); } return ( -
-
-

- {mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"} -

-

- {mode === "sign_in" - ? "Use your email and password to access this instance." - : "Create an account for this instance. Email confirmation is not required in v1."} -

+
+ {/* Left half — form */} +
+
+
+ + Paperclip +
-
{ - event.preventDefault(); - mutation.mutate(); - }} - > - {mode === "sign_up" && ( - - )} - - - {error &&

{error}

} - -
+

+ {mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"} +

+

+ {mode === "sign_in" + ? "Use your email and password to access this instance." + : "Create an account for this instance. Email confirmation is not required in v1."} +

-
- {mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "} - + {mode === "sign_up" && ( +
+ + setName(event.target.value)} + autoComplete="name" + autoFocus + /> +
+ )} +
+ + setEmail(event.target.value)} + autoComplete="email" + autoFocus={mode === "sign_in"} + /> +
+
+ + setPassword(event.target.value)} + autoComplete={mode === "sign_in" ? "current-password" : "new-password"} + /> +
+ {error &&

{error}

} + + + +
+ {mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "} + +
+ + {/* Right half — ASCII art animation (hidden on mobile) */} +
+ +
); } diff --git a/ui/src/pages/BoardClaim.tsx b/ui/src/pages/BoardClaim.tsx index ab8ab7a8..334a0861 100644 --- a/ui/src/pages/BoardClaim.tsx +++ b/ui/src/pages/BoardClaim.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link, useParams, useSearchParams } from "react-router-dom"; +import { Link, useParams, useSearchParams } from "@/lib/router"; import { accessApi } from "../api/access"; import { authApi } from "../api/auth"; import { queryKeys } from "../lib/queryKeys"; @@ -117,7 +117,7 @@ export function BoardClaimPage() { onClick={() => claimMutation.mutate()} disabled={claimMutation.isPending} > - {claimMutation.isPending ? "Claiming..." : "Claim ownership"} + {claimMutation.isPending ? "Claiming…" : "Claim ownership"}
diff --git a/ui/src/pages/Companies.tsx b/ui/src/pages/Companies.tsx index 6d1ff8f0..f5e02f32 100644 --- a/ui/src/pages/Companies.tsx +++ b/ui/src/pages/Companies.tsx @@ -283,7 +283,7 @@ export function Companies() { onClick={() => deleteMutation.mutate(company.id)} disabled={deleteMutation.isPending} > - {deleteMutation.isPending ? "Deleting..." : "Delete"} + {deleteMutation.isPending ? "Deleting…" : "Delete"}
diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index dd737a68..12207f09 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -5,6 +5,7 @@ import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; import { formatCents, formatTokens } from "../lib/utils"; import { Identity } from "../components/Identity"; import { StatusBadge } from "../components/StatusBadge"; @@ -89,6 +90,10 @@ export function Costs() { return ; } + if (isLoading) { + return ; + } + const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"]; return ( @@ -124,7 +129,6 @@ export function Costs() { )}
- {isLoading &&

Loading...

} {error &&

{error.message}

} {data && ( @@ -151,7 +155,7 @@ export function Costs() { {data.summary.budgetCents > 0 && (
90 ? "bg-red-400" : data.summary.utilizationPercent > 70 diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index a50129ad..a0449c02 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { dashboardApi } from "../api/dashboard"; import { activityApi } from "../api/activity"; @@ -22,6 +22,7 @@ import { cn, formatCents } from "../lib/utils"; import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react"; import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel"; import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; +import { PageSkeleton } from "../components/PageSkeleton"; import type { Agent, Issue } from "@paperclip/shared"; function getRecentIssues(issues: Issue[]): Issue[] { @@ -177,9 +178,12 @@ export function Dashboard() { ); } + if (isLoading) { + return ; + } + return (
- {isLoading &&

Loading...

} {error &&

{error.message}

} @@ -256,11 +260,11 @@ export function Dashboard() {
{/* Recent Activity */} {recentActivity.length > 0 && ( -
+

Recent Activity

-
+
{recentActivity.map((event) => ( +

Recent Tasks

@@ -285,7 +289,7 @@ export function Dashboard() {

No tasks yet.

) : ( -
+
{recentIssues.slice(0, 10).map((issue) => (
-

+

{issue.title} {issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 285b1fec..b2ec4f5a 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1038,7 +1038,7 @@ export function DesignGuide() {

diff --git a/ui/src/pages/GoalDetail.tsx b/ui/src/pages/GoalDetail.tsx index a3132be3..0673ae90 100644 --- a/ui/src/pages/GoalDetail.tsx +++ b/ui/src/pages/GoalDetail.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useParams } from "react-router-dom"; +import { useParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { goalsApi } from "../api/goals"; import { projectsApi } from "../api/projects"; @@ -14,6 +14,8 @@ import { GoalTree } from "../components/GoalTree"; import { StatusBadge } from "../components/StatusBadge"; import { InlineEditor } from "../components/InlineEditor"; import { EntityRow } from "../components/EntityRow"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { projectUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Plus } from "lucide-react"; @@ -21,7 +23,7 @@ import type { Goal, Project } from "@paperclip/shared"; export function GoalDetail() { const { goalId } = useParams<{ goalId: string }>(); - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); const { openNewGoal } = useDialog(); const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -36,19 +38,25 @@ export function GoalDetail() { queryFn: () => goalsApi.get(goalId!), enabled: !!goalId }); + const resolvedCompanyId = goal?.companyId ?? selectedCompanyId; const { data: allGoals } = useQuery({ - queryKey: queryKeys.goals.list(selectedCompanyId!), - queryFn: () => goalsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId + queryKey: queryKeys.goals.list(resolvedCompanyId!), + queryFn: () => goalsApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId }); const { data: allProjects } = useQuery({ - queryKey: queryKeys.projects.list(selectedCompanyId!), - queryFn: () => projectsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId + queryKey: queryKeys.projects.list(resolvedCompanyId!), + queryFn: () => projectsApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId }); + useEffect(() => { + if (!goal?.companyId || goal.companyId === selectedCompanyId) return; + setSelectedCompanyId(goal.companyId, { source: "route_sync" }); + }, [goal?.companyId, selectedCompanyId, setSelectedCompanyId]); + const updateGoal = useMutation({ mutationFn: (data: Record) => goalsApi.update(goalId!, data), @@ -56,9 +64,9 @@ export function GoalDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(goalId!) }); - if (selectedCompanyId) { + if (resolvedCompanyId) { queryClient.invalidateQueries({ - queryKey: queryKeys.goals.list(selectedCompanyId) + queryKey: queryKeys.goals.list(resolvedCompanyId) }); } } @@ -66,9 +74,9 @@ export function GoalDetail() { const uploadImage = useMutation({ mutationFn: async (file: File) => { - if (!selectedCompanyId) throw new Error("No company selected"); + if (!resolvedCompanyId) throw new Error("No company selected"); return assetsApi.uploadImage( - selectedCompanyId, + resolvedCompanyId, file, `goals/${goalId ?? "draft"}` ); @@ -102,8 +110,7 @@ export function GoalDetail() { return () => closePanel(); }, [goal]); // eslint-disable-line react-hooks/exhaustive-deps - if (isLoading) - return

Loading...

; + if (isLoading) return ; if (error) return

{error.message}

; if (!goal) return null; @@ -176,7 +183,7 @@ export function GoalDetail() { key={project.id} title={project.name} subtitle={project.description ?? undefined} - to={`/projects/${project.id}`} + to={projectUrl(project)} trailing={} /> ))} diff --git a/ui/src/pages/Goals.tsx b/ui/src/pages/Goals.tsx index 51514b2b..490bb048 100644 --- a/ui/src/pages/Goals.tsx +++ b/ui/src/pages/Goals.tsx @@ -7,6 +7,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { GoalTree } from "../components/GoalTree"; import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; import { Button } from "@/components/ui/button"; import { Target, Plus } from "lucide-react"; @@ -29,9 +30,12 @@ export function Goals() { return ; } + if (isLoading) { + return ; + } + return (
- {isLoading &&

Loading...

} {error &&

{error.message}

} {goals && goals.length === 0 && ( diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 19682b65..b32e7e5d 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useLocation, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; @@ -14,6 +14,7 @@ import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; import { ApprovalCard } from "../components/ApprovalCard"; import { StatusBadge } from "../components/StatusBadge"; import { timeAgo } from "../lib/timeAgo"; @@ -208,7 +209,7 @@ function FailedRunCard({ disabled={retryRun.isPending} > - {retryRun.isPending ? "Retrying..." : "Retry"} + {retryRun.isPending ? "Retrying…" : "Retry"}
); @@ -276,7 +296,7 @@ export function InviteLandingPage() { onClick={() => acceptMutation.mutate()} > {acceptMutation.isPending - ? "Submitting..." + ? "Submitting…" : invite.inviteType === "bootstrap_ceo" ? "Accept bootstrap invite" : "Submit join request"} diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 45cfd750..84eb3ab4 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useSearchParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx index b5715109..ea717c6d 100644 --- a/ui/src/pages/MyIssues.tsx +++ b/ui/src/pages/MyIssues.tsx @@ -8,6 +8,7 @@ import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { EntityRow } from "../components/EntityRow"; import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; import { formatDate } from "../lib/utils"; import { ListTodo } from "lucide-react"; @@ -29,6 +30,10 @@ export function MyIssues() { return ; } + if (isLoading) { + return ; + } + // Show issues that are not assigned (user-created or unassigned) const myIssues = (issues ?? []).filter( (i) => !i.assigneeAgentId && !["done", "cancelled"].includes(i.status) @@ -36,10 +41,9 @@ export function MyIssues() { return (
- {isLoading &&

Loading...

} {error &&

{error.message}

} - {!isLoading && myIssues.length === 0 && ( + {myIssues.length === 0 && ( )} diff --git a/ui/src/pages/Org.tsx b/ui/src/pages/Org.tsx index 2928e104..2cbc1816 100644 --- a/ui/src/pages/Org.tsx +++ b/ui/src/pages/Org.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; @@ -7,6 +7,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { StatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; import { ChevronRight, GitBranch } from "lucide-react"; import { cn } from "../lib/utils"; @@ -106,9 +107,12 @@ export function Org() { return ; } + if (isLoading) { + return ; + } + return (
- {isLoading &&

Loading...

} {error &&

{error.message}

} {data && data.length === 0 && ( diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 7f5cb387..c0ed8dd3 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { agentUrl } from "../lib/utils"; import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; import { Network } from "lucide-react"; import type { Agent } from "@paperclip/shared"; @@ -254,7 +256,7 @@ export function OrgChart() { } if (isLoading) { - return

Loading...

; + return ; } if (orgTree && orgTree.length === 0) { @@ -287,6 +289,7 @@ export function OrgChart() { } setZoom(newZoom); }} + aria-label="Zoom in" > + @@ -303,6 +306,7 @@ export function OrgChart() { } setZoom(newZoom); }} + aria-label="Zoom out" > − @@ -321,6 +325,7 @@ export function OrgChart() { setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 }); }} title="Fit to screen" + aria-label="Fit chart to screen" > Fit @@ -371,14 +376,14 @@ export function OrgChart() {
navigate(`/agents/${node.id}`)} + onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)} >
{/* Agent icon + status dot */} diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 1d775bc2..dfe67885 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, useLocation, Navigate } from "react-router-dom"; +import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { PROJECT_COLORS } from "@paperclip/shared"; +import { PROJECT_COLORS, isUuidLike } from "@paperclip/shared"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -15,15 +15,20 @@ import { ProjectProperties } from "../components/ProjectProperties"; import { InlineEditor } from "../components/InlineEditor"; import { StatusBadge } from "../components/StatusBadge"; import { IssuesList } from "../components/IssuesList"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { projectRouteRef } from "../lib/utils"; /* ── Top-level tab types ── */ type ProjectTab = "overview" | "list"; function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null { - const prefix = `/projects/${projectId}`; - if (pathname === `${prefix}/overview`) return "overview"; - if (pathname.startsWith(`${prefix}/issues`)) return "list"; + const segments = pathname.split("/").filter(Boolean); + const projectsIdx = segments.indexOf("projects"); + if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null; + const tab = segments[projectsIdx + 2]; + if (tab === "overview") return "overview"; + if (tab === "issues") return "list"; return null; } @@ -95,7 +100,7 @@ function ColorPicker({
); diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index c4af6867..6fe80ada 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -8,7 +8,8 @@ import { queryKeys } from "../lib/queryKeys"; import { EntityRow } from "../components/EntityRow"; import { StatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; -import { formatDate } from "../lib/utils"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { formatDate, projectUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Hexagon, Plus } from "lucide-react"; @@ -31,6 +32,10 @@ export function Projects() { return ; } + if (isLoading) { + return ; + } + return (
@@ -40,7 +45,6 @@ export function Projects() {
- {isLoading &&

Loading...

} {error &&

{error.message}

} {projects && projects.length === 0 && ( @@ -59,7 +63,7 @@ export function Projects() { key={project.id} title={project.name} subtitle={project.description ?? undefined} - to={`/projects/${project.id}`} + to={projectUrl(project)} trailing={
{project.targetDate && (