feat(ui): reconcile backup UI changes with current routing and interaction features

This commit is contained in:
Dotta
2026-03-02 16:44:03 -06:00
parent 83be94361c
commit 8ee063c4e5
69 changed files with 1591 additions and 666 deletions

View File

@@ -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 { useQuery } from "@tanstack/react-query";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import { authApi } from "./api/auth"; import { authApi } from "./api/auth";
@@ -25,6 +25,7 @@ import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim"; import { BoardClaimPage } from "./pages/BoardClaim";
import { InviteLandingPage } from "./pages/InviteLanding"; import { InviteLandingPage } from "./pages/InviteLanding";
import { queryKeys } from "./lib/queryKeys"; import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
function BootstrapPendingPage() { function BootstrapPendingPage() {
return ( return (
@@ -127,6 +128,50 @@ function boardRoutes() {
); );
} }
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
if (loading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
return (
<div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">
No accessible companies found.
</div>
);
}
return <Navigate to={`/${targetCompany.issuePrefix}/dashboard`} replace />;
}
function UnprefixedBoardRedirect() {
const location = useLocation();
const { companies, selectedCompany, loading } = useCompany();
if (loading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
return (
<div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">
No accessible companies found.
</div>
);
}
return (
<Navigate
to={`/${targetCompany.issuePrefix}${location.pathname}${location.search}${location.hash}`}
replace
/>
);
}
export function App() { export function App() {
return ( return (
<Routes> <Routes>
@@ -135,14 +180,21 @@ export function App() {
<Route path="invite/:token" element={<InviteLandingPage />} /> <Route path="invite/:token" element={<InviteLandingPage />} />
<Route element={<CloudAccessGate />}> <Route element={<CloudAccessGate />}>
{/* Company-prefixed routes: /PAP/issues/PAP-214 */} <Route index element={<CompanyRootRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/runs/:runId" element={<UnprefixedBoardRedirect />} />
<Route path="projects" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}> <Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()} {boardRoutes()}
</Route> </Route>
{/* Non-prefixed routes: /issues/PAP-214 */}
<Route element={<Layout />}>
{boardRoutes()}
</Route>
</Route> </Route>
</Routes> </Routes>
); );

View File

@@ -27,6 +27,12 @@ type AgentJoinRequestAccepted = JoinRequest & {
claimSecret: string; claimSecret: string;
claimApiKeyPath: string; claimApiKeyPath: string;
onboarding?: Record<string, unknown>; onboarding?: Record<string, unknown>;
diagnostics?: Array<{
code: string;
level: "info" | "warn";
message: string;
hint?: string;
}>;
}; };
type InviteOnboardingManifest = { type InviteOnboardingManifest = {

View File

@@ -8,7 +8,8 @@ import type {
Approval, Approval,
AgentConfigRevision, AgentConfigRevision,
} from "@paperclip/shared"; } from "@paperclip/shared";
import { api } from "./client"; import { isUuidLike, normalizeAgentUrlKey } from "@paperclip/shared";
import { ApiError, api } from "./client";
export interface AgentKey { export interface AgentKey {
id: string; id: string;
@@ -44,37 +45,78 @@ export interface AgentHireResponse {
approval: Approval | null; 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 = { export const agentsApi = {
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`), list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`), org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
listConfigurations: (companyId: string) => listConfigurations: (companyId: string) =>
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`), api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
get: (id: string) => api.get<Agent>(`/agents/${id}`), get: async (id: string, companyId?: string) => {
getConfiguration: (id: string) => api.get<Record<string, unknown>>(`/agents/${id}/configuration`), try {
listConfigRevisions: (id: string) => return await api.get<Agent>(agentPath(id, companyId));
api.get<AgentConfigRevision[]>(`/agents/${id}/config-revisions`), } catch (error) {
getConfigRevision: (id: string, revisionId: string) => // Backward-compat fallback: if backend shortname lookup reports ambiguity,
api.get<AgentConfigRevision>(`/agents/${id}/config-revisions/${revisionId}`), // resolve using company agent list while ignoring terminated agents.
rollbackConfigRevision: (id: string, revisionId: string) => if (
api.post<Agent>(`/agents/${id}/config-revisions/${revisionId}/rollback`, {}), !(error instanceof ApiError) ||
error.status !== 409 ||
!companyId ||
isUuidLike(id)
) {
throw error;
}
const urlKey = normalizeAgentUrlKey(id);
if (!urlKey) throw error;
const agents = await api.get<Agent[]>(`/companies/${companyId}/agents`);
const matches = agents.filter(
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
);
if (matches.length !== 1) throw error;
return api.get<Agent>(agentPath(matches[0]!.id, companyId));
}
},
getConfiguration: (id: string, companyId?: string) =>
api.get<Record<string, unknown>>(agentPath(id, companyId, "/configuration")),
listConfigRevisions: (id: string, companyId?: string) =>
api.get<AgentConfigRevision[]>(agentPath(id, companyId, "/config-revisions")),
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
api.get<AgentConfigRevision>(agentPath(id, companyId, `/config-revisions/${revisionId}`)),
rollbackConfigRevision: (id: string, revisionId: string, companyId?: string) =>
api.post<Agent>(agentPath(id, companyId, `/config-revisions/${revisionId}/rollback`), {}),
create: (companyId: string, data: Record<string, unknown>) => create: (companyId: string, data: Record<string, unknown>) =>
api.post<Agent>(`/companies/${companyId}/agents`, data), api.post<Agent>(`/companies/${companyId}/agents`, data),
hire: (companyId: string, data: Record<string, unknown>) => hire: (companyId: string, data: Record<string, unknown>) =>
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data), api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Agent>(`/agents/${id}`, data), update: (id: string, data: Record<string, unknown>, companyId?: string) =>
updatePermissions: (id: string, data: { canCreateAgents: boolean }) => api.patch<Agent>(agentPath(id, companyId), data),
api.patch<Agent>(`/agents/${id}/permissions`, data), updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) =>
pause: (id: string) => api.post<Agent>(`/agents/${id}/pause`, {}), api.patch<Agent>(agentPath(id, companyId, "/permissions"), data),
resume: (id: string) => api.post<Agent>(`/agents/${id}/resume`, {}), pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
terminate: (id: string) => api.post<Agent>(`/agents/${id}/terminate`, {}), resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
remove: (id: string) => api.delete<{ ok: true }>(`/agents/${id}`), terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
listKeys: (id: string) => api.get<AgentKey[]>(`/agents/${id}/keys`), remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }), listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
revokeKey: (agentId: string, keyId: string) => api.delete<{ ok: true }>(`/agents/${agentId}/keys/${keyId}`), createKey: (id: string, name: string, companyId?: string) =>
runtimeState: (id: string) => api.get<AgentRuntimeState>(`/agents/${id}/runtime-state`), api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
taskSessions: (id: string) => api.get<AgentTaskSession[]>(`/agents/${id}/task-sessions`), revokeKey: (agentId: string, keyId: string, companyId?: string) =>
resetSession: (id: string, taskKey?: string | null) => api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)),
api.post<void>(`/agents/${id}/runtime-state/reset-session`, { taskKey: taskKey ?? null }), runtimeState: (id: string, companyId?: string) =>
api.get<AgentRuntimeState>(agentPath(id, companyId, "/runtime-state")),
taskSessions: (id: string, companyId?: string) =>
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`), adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
testEnvironment: ( testEnvironment: (
companyId: string, companyId: string,
@@ -85,7 +127,7 @@ export const agentsApi = {
`/companies/${companyId}/adapters/${type}/test-environment`, `/companies/${companyId}/adapters/${type}/test-environment`,
data, data,
), ),
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}), invoke: (id: string, companyId?: string) => api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), {}),
wakeup: ( wakeup: (
id: string, id: string,
data: { data: {
@@ -95,6 +137,8 @@ export const agentsApi = {
payload?: Record<string, unknown> | null; payload?: Record<string, unknown> | null;
idempotencyKey?: string | null; idempotencyKey?: string | null;
}, },
) => api.post<HeartbeatRun | { status: "skipped" }>(`/agents/${id}/wakeup`, data), companyId?: string,
loginWithClaude: (id: string) => api.post<ClaudeLoginResult>(`/agents/${id}/claude-login`, {}), ) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
}; };

View File

@@ -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"; import { api } from "./client";
export type CompanyStats = Record<string, { agentCount: number; issueCount: number }>; export type CompanyStats = Record<string, { agentCount: number; issueCount: number }>;
@@ -20,4 +27,10 @@ export const companiesApi = {
) => api.patch<Company>(`/companies/${companyId}`, data), ) => api.patch<Company>(`/companies/${companyId}`, data),
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}), archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`), remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
exportBundle: (companyId: string, data: { include?: { company?: boolean; agents?: boolean } }) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
importPreview: (data: CompanyPortabilityPreviewRequest) =>
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
importBundle: (data: CompanyPortabilityImportRequest) =>
api.post<CompanyPortabilityImportResult>("/companies/import", data),
}; };

View File

@@ -4,6 +4,9 @@ export type HealthStatus = {
deploymentExposure?: "private" | "public"; deploymentExposure?: "private" | "public";
authReady?: boolean; authReady?: boolean;
bootstrapStatus?: "ready" | "bootstrap_pending"; bootstrapStatus?: "ready" | "bootstrap_pending";
features?: {
companyDeletionEnabled?: boolean;
};
}; };
export const healthApi = { export const healthApi = {

View File

@@ -39,8 +39,15 @@ export const issuesApi = {
}), }),
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}), release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`), listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
addComment: (id: string, body: string, reopen?: boolean) => addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean) =>
api.post<IssueComment>(`/issues/${id}/comments`, reopen === undefined ? { body } : { body, reopen }), api.post<IssueComment>(
`/issues/${id}/comments`,
{
body,
...(reopen === undefined ? {} : { reopen }),
...(interrupt === undefined ? {} : { interrupt }),
},
),
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`), listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
uploadAttachment: ( uploadAttachment: (
companyId: string, companyId: string,

View File

@@ -1,19 +1,33 @@
import type { Project, ProjectWorkspace } from "@paperclip/shared"; import type { Project, ProjectWorkspace } from "@paperclip/shared";
import { api } from "./client"; 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 = { export const projectsApi = {
list: (companyId: string) => api.get<Project[]>(`/companies/${companyId}/projects`), list: (companyId: string) => api.get<Project[]>(`/companies/${companyId}/projects`),
get: (id: string) => api.get<Project>(`/projects/${id}`), get: (id: string, companyId?: string) => api.get<Project>(projectPath(id, companyId)),
create: (companyId: string, data: Record<string, unknown>) => create: (companyId: string, data: Record<string, unknown>) =>
api.post<Project>(`/companies/${companyId}/projects`, data), api.post<Project>(`/companies/${companyId}/projects`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Project>(`/projects/${id}`, data), update: (id: string, data: Record<string, unknown>, companyId?: string) =>
listWorkspaces: (projectId: string) => api.patch<Project>(projectPath(id, companyId), data),
api.get<ProjectWorkspace[]>(`/projects/${projectId}/workspaces`), listWorkspaces: (projectId: string, companyId?: string) =>
createWorkspace: (projectId: string, data: Record<string, unknown>) => api.get<ProjectWorkspace[]>(projectPath(projectId, companyId, "/workspaces")),
api.post<ProjectWorkspace>(`/projects/${projectId}/workspaces`, data), createWorkspace: (projectId: string, data: Record<string, unknown>, companyId?: string) =>
updateWorkspace: (projectId: string, workspaceId: string, data: Record<string, unknown>) => api.post<ProjectWorkspace>(projectPath(projectId, companyId, "/workspaces"), data),
api.patch<ProjectWorkspace>(`/projects/${projectId}/workspaces/${workspaceId}`, data), updateWorkspace: (projectId: string, workspaceId: string, data: Record<string, unknown>, companyId?: string) =>
removeWorkspace: (projectId: string, workspaceId: string) => api.patch<ProjectWorkspace>(
api.delete<ProjectWorkspace>(`/projects/${projectId}/workspaces/${workspaceId}`), projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`),
remove: (id: string) => api.delete<Project>(`/projects/${id}`), data,
),
removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) =>
api.delete<ProjectWorkspace>(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)),
remove: (id: string, companyId?: string) => api.delete<Project>(projectPath(id, companyId)),
}; };

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; 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 { useQuery } from "@tanstack/react-query";
import type { Issue, LiveEvent } from "@paperclip/shared"; import type { Issue, LiveEvent } from "@paperclip/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";

View File

@@ -1,9 +1,8 @@
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import type { ActivityEvent } from "@paperclip/shared"; import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclip/shared";
import type { Agent } from "@paperclip/shared";
const ACTION_VERBS: Record<string, string> = { const ACTION_VERBS: Record<string, string> = {
"issue.created": "created", "issue.created": "created",
@@ -70,7 +69,7 @@ function entityLink(entityType: string, entityId: string, name?: string | null):
switch (entityType) { switch (entityType) {
case "issue": return `/issues/${name ?? entityId}`; case "issue": return `/issues/${name ?? entityId}`;
case "agent": return `/agents/${entityId}`; case "agent": return `/agents/${entityId}`;
case "project": return `/projects/${entityId}`; case "project": return `/projects/${deriveProjectUrlKey(name, entityId)}`;
case "goal": return `/goals/${entityId}`; case "goal": return `/goals/${entityId}`;
case "approval": return `/approvals/${entityId}`; case "approval": return `/approvals/${entityId}`;
default: return null; default: return null;

View File

@@ -1,12 +1,12 @@
import { useQuery } from "@tanstack/react-query"; 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 type { Agent, AgentRuntimeState } from "@paperclip/shared";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { formatDate } from "../lib/utils"; import { formatDate, agentUrl } from "../lib/utils";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
interface AgentPropertiesProps { interface AgentPropertiesProps {
@@ -84,7 +84,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
{agent.reportsTo && ( {agent.reportsTo && (
<PropertyRow label="Reports To"> <PropertyRow label="Reports To">
{reportsToAgent ? ( {reportsToAgent ? (
<Link to={`/agents/${reportsToAgent.id}`} className="hover:underline"> <Link to={agentUrl(reportsToAgent)} className="hover:underline">
<Identity name={reportsToAgent.name} size="sm" /> <Identity name={reportsToAgent.name} size="sm" />
</Link> </Link>
) : ( ) : (

View File

@@ -1,5 +1,5 @@
import { CheckCircle2, XCircle, Clock } from "lucide-react"; 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 { Button } from "@/components/ui/button";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload"; import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";

View File

@@ -1,16 +1,45 @@
import { useEffect, useRef } from "react"; 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; x: number;
y: number; y: number;
vx: number; vx: number;
vy: number; vy: number;
char: string;
life: number; life: number;
maxLife: number; maxLife: number;
phase: number; drift: number;
sprite: PaperclipSprite;
width: number;
height: number;
} }
function measureChar(container: HTMLElement): { w: number; h: 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 }; 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() { export function AsciiArtAnimation() {
const preRef = useRef<HTMLPreElement>(null); const preRef = useRef<HTMLPreElement>(null);
const frameRef = useRef(0); const frameRef = useRef<number | null>(null);
const particlesRef = useRef<Particle[]>([]);
useEffect(() => { useEffect(() => {
if (!preRef.current) return; if (!preRef.current) return;
const preEl: HTMLPreElement = preRef.current; 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); function toGlyph(value: number): string {
let charW = charSize.w; const clamped = Math.max(0, Math.min(0.999, value));
let charH = charSize.h; const idx = Math.floor(clamped * CHARS.length);
let cols = Math.ceil(preEl.clientWidth / charW); return CHARS[idx] ?? " ";
let rows = Math.ceil(preEl.clientHeight / charH); }
let particles = particlesRef.current;
function spawnParticle() { function rebuildGrid() {
const edge = Math.random(); const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
let x: number, y: number, vx: number, vy: number; const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
if (edge < 0.5) { if (nextCols === cols && nextRows === rows) return;
x = -1;
y = Math.random() * rows; cols = nextCols;
vx = 0.3 + Math.random() * 0.5; rows = nextRows;
vy = (Math.random() - 0.5) * 0.2; const cellCount = cols * rows;
} else { trail = new Float32Array(cellCount);
x = Math.random() * cols; colWave = new Float32Array(cols);
y = rows + 1; rowWave = new Float32Array(rows);
vx = (Math.random() - 0.5) * 0.2; clipMask = new Uint16Array(cellCount);
vy = -(0.2 + Math.random() * 0.4); 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({ const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
x, y, vx, vy, for (let r = 0; r < rows; r++) {
char: CHARS[Math.floor(Math.random() * CHARS.length)], 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, life: 0,
maxLife, maxLife: 260 + Math.random() * 220,
phase: Math.random() * Math.PI * 2, drift: (Math.random() - 0.5) * 1.2,
sprite,
width: size.width,
height: size.height,
}); });
} }
function render(time: number) { function stampClip(clip: Clip, alpha: number) {
const t = time * 0.001; const baseCol = Math.round(clip.x);
const baseRow = Math.round(clip.y);
// Spawn particles for (let sr = 0; sr < clip.sprite.length; sr++) {
const targetCount = Math.floor((cols * rows) / 12); const line = clip.sprite[sr]!;
while (particles.length < targetCount) { const row = baseRow + sr;
spawnParticle(); if (row < 0 || row >= rows) continue;
} for (let sc = 0; sc < line.length; sc++) {
const ch = line[sc] ?? " ";
// Build grid if (ch === " ") continue;
const grid: string[][] = Array.from({ length: rows }, () => const col = baseCol + sc;
Array.from({ length: cols }, () => " ") if (col < 0 || col >= cols) continue;
); const idx = row * cols + col;
const opacity: number[][] = Array.from({ length: rows }, () => const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
Array.from({ length: cols }, () => 0) trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
); clipMask[idx] = ch.charCodeAt(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);
}
} }
} }
}
// Update and render particles function step(time: number) {
for (let i = particles.length - 1; i >= 0; i--) { if (!loopActive) return;
const p = particles[i]; frameRef.current = requestAnimationFrame(step);
p.life++; if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
// Flow field influence const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
const angle = lastRenderAt = time;
Math.sin(p.x * 0.05 + t * 0.3) * Math.cos(p.y * 0.07 - t * 0.2) * tick += delta;
Math.PI;
p.vx += Math.cos(angle) * 0.02;
p.vy += Math.sin(angle) * 0.02;
// Damping const cellCount = cols * rows;
p.vx *= 0.98; const targetCount = Math.max(3, Math.floor(cellCount / 2200));
p.vy *= 0.98; while (clips.length < targetCount) spawnClip();
p.x += p.vx; for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
p.y += p.vy; clipMask.fill(0);
// Life fade for (let i = clips.length - 1; i >= 0; i--) {
const lifeFrac = p.life / p.maxLife; const clip = clips[i]!;
const alpha = lifeFrac < 0.1 clip.life += delta;
? lifeFrac / 0.1
: lifeFrac > 0.8 const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
? (1 - lifeFrac) / 0.2 const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
: 1; 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 ( if (
p.life >= p.maxLife || clip.life >= clip.maxLife ||
p.x < -2 || p.x > cols + 2 || clip.x < -clip.width - 2 ||
p.y < -2 || p.y > rows + 2 clip.x > cols + 2 ||
clip.y < -clip.height - 2 ||
clip.y > rows + 2
) { ) {
particles.splice(i, 1); clips.splice(i, 1);
continue; continue;
} }
const col = Math.round(p.x); const life = clip.life / clip.maxLife;
const row = Math.round(p.y); const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
if (row >= 0 && row < rows && col >= 0 && col < cols) { stampClip(clip, alpha);
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;
}
}
} }
// 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 = ""; let output = "";
for (let r = 0; r < rows; r++) { for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) { for (let c = 0; c < cols; c++) {
const a = opacity[r][c]; const idx = r * cols + c;
if (a > 0 && grid[r][c] !== " ") { const clipChar = clipMask[idx];
const o = Math.round(a * 60 + 40); if (clipChar > 0) {
output += `<span style="opacity:${o}%">${grid[r][c]}</span>`; output += String.fromCharCode(clipChar);
} else { continue;
output += " ";
} }
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"; if (r < rows - 1) output += "\n";
} }
preEl.innerHTML = output; if (output !== lastOutput) {
frameRef.current = requestAnimationFrame(render); 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 observer = new ResizeObserver(() => {
const size = measureChar(preEl); const size = measureChar(preEl);
charW = size.w; charW = size.w;
charH = size.h; charH = size.h;
cols = Math.ceil(preEl.clientWidth / charW); rebuildGrid();
rows = Math.ceil(preEl.clientHeight / charH); syncLoop();
// 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;
}); });
observer.observe(preEl); 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 () => { return () => {
cancelAnimationFrame(frameRef.current); loopActive = false;
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
observer.disconnect(); observer.disconnect();
document.removeEventListener("visibilitychange", onVisibilityChange);
motionMedia.removeEventListener("change", onMotionChange);
}; };
}, []); }, []);

View File

@@ -1,4 +1,4 @@
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
@@ -25,6 +25,7 @@ export function BreadcrumbBar() {
size="icon-sm" size="icon-sm"
className="mr-2 shrink-0" className="mr-2 shrink-0"
onClick={toggleSidebar} onClick={toggleSidebar}
aria-label="Open sidebar"
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
@@ -29,6 +29,7 @@ import {
Plus, Plus,
} from "lucide-react"; } from "lucide-react";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { agentUrl, projectUrl } from "../lib/utils";
export function CommandPalette() { export function CommandPalette() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -174,10 +175,9 @@ export function CommandPalette() {
key={issue.id} key={issue.id}
value={ value={
searchQuery.length > 0 searchQuery.length > 0
? `${searchQuery} ${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}` ? `${searchQuery} ${issue.identifier ?? ""} ${issue.title}`
: undefined : undefined
} }
keywords={issue.description ? [issue.description] : undefined}
onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)} onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)}
> >
<CircleDot className="mr-2 h-4 w-4" /> <CircleDot className="mr-2 h-4 w-4" />
@@ -200,7 +200,7 @@ export function CommandPalette() {
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading="Agents"> <CommandGroup heading="Agents">
{agents.slice(0, 10).map((agent) => ( {agents.slice(0, 10).map((agent) => (
<CommandItem key={agent.id} onSelect={() => go(`/agents/${agent.id}`)}> <CommandItem key={agent.id} onSelect={() => go(agentUrl(agent))}>
<Bot className="mr-2 h-4 w-4" /> <Bot className="mr-2 h-4 w-4" />
{agent.name} {agent.name}
<span className="text-xs text-muted-foreground ml-2">{agent.role}</span> <span className="text-xs text-muted-foreground ml-2">{agent.role}</span>
@@ -215,7 +215,7 @@ export function CommandPalette() {
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading="Projects"> <CommandGroup heading="Projects">
{projects.slice(0, 10).map((project) => ( {projects.slice(0, 10).map((project) => (
<CommandItem key={project.id} onSelect={() => go(`/projects/${project.id}`)}> <CommandItem key={project.id} onSelect={() => go(projectUrl(project))}>
<Hexagon className="mr-2 h-4 w-4" /> <Hexagon className="mr-2 h-4 w-4" />
{project.name} {project.name}
</CommandItem> </CommandItem>

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Paperclip, Plus } from "lucide-react"; import { Paperclip, Plus } from "lucide-react";
import { useQueries } from "@tanstack/react-query";
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -18,6 +19,9 @@ import { CSS } from "@dnd-kit/utilities";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -65,10 +69,14 @@ function sortByStoredOrder(companies: Company[]): Company[] {
function SortableCompanyItem({ function SortableCompanyItem({
company, company,
isSelected, isSelected,
hasLiveAgents,
hasUnreadInbox,
onSelect, onSelect,
}: { }: {
company: Company; company: Company;
isSelected: boolean; isSelected: boolean;
hasLiveAgents: boolean;
hasUnreadInbox: boolean;
onSelect: () => void; onSelect: () => void;
}) { }) {
const { const {
@@ -88,28 +96,28 @@ function SortableCompanyItem({
}; };
return ( return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}> <div ref={setNodeRef} style={style} {...attributes} {...listeners} className="overflow-visible">
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<a <a
href={`/dashboard?company=${company.id}`} href={`/${company.issuePrefix}/dashboard`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onSelect(); onSelect();
}} }}
className="relative flex items-center justify-center group" className="relative flex items-center justify-center group overflow-visible"
> >
{/* Selection indicator pill */} {/* Selection indicator pill */}
<div <div
className={cn( className={cn(
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-all duration-200", "absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-[height] duration-150",
isSelected isSelected
? "h-5" ? "h-5"
: "h-0 group-hover:h-2" : "h-0 group-hover:h-2"
)} )}
/> />
<div <div
className={cn("transition-all duration-200", isDragging && "scale-105")} className={cn("relative overflow-visible transition-transform duration-150", isDragging && "scale-105")}
> >
<CompanyPatternIcon <CompanyPatternIcon
companyName={company.name} companyName={company.name}
@@ -121,6 +129,17 @@ function SortableCompanyItem({
isDragging && "shadow-lg", isDragging && "shadow-lg",
)} )}
/> />
{hasLiveAgents && (
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-80" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
</span>
</span>
)}
{hasUnreadInbox && (
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-background" />
)}
</div> </div>
</a> </a>
</TooltipTrigger> </TooltipTrigger>
@@ -139,6 +158,36 @@ export function CompanyRail() {
() => companies.filter((company) => company.status !== "archived"), () => companies.filter((company) => company.status !== "archived"),
[companies], [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<string, boolean>();
companyIds.forEach((companyId, index) => {
result.set(companyId, (liveRunsQueries[index]?.data?.length ?? 0) > 0);
});
return result;
}, [companyIds, liveRunsQueries]);
const hasUnreadInboxByCompanyId = useMemo(() => {
const result = new Map<string, boolean>();
companyIds.forEach((companyId, index) => {
result.set(companyId, (sidebarBadgeQueries[index]?.data?.inbox ?? 0) > 0);
});
return result;
}, [companyIds, sidebarBadgeQueries]);
// Maintain sorted order in local state, synced from companies + localStorage // Maintain sorted order in local state, synced from companies + localStorage
const [orderedIds, setOrderedIds] = useState<string[]>(() => const [orderedIds, setOrderedIds] = useState<string[]>(() =>
@@ -219,7 +268,7 @@ export function CompanyRail() {
</div> </div>
{/* Company list */} {/* Company list */}
<div className="flex-1 flex flex-col items-center gap-2 py-2 overflow-y-auto scrollbar-none"> <div className="flex-1 flex flex-col items-center gap-2 py-3 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -234,6 +283,8 @@ export function CompanyRail() {
key={company.id} key={company.id}
company={company} company={company}
isSelected={company.id === selectedCompanyId} isSelected={company.id === selectedCompanyId}
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
onSelect={() => setSelectedCompanyId(company.id)} onSelect={() => setSelectedCompanyId(company.id)}
/> />
))} ))}
@@ -250,7 +301,8 @@ export function CompanyRail() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
onClick={() => openOnboarding()} onClick={() => openOnboarding()}
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-all duration-200" className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-[border-color,color,border-radius] duration-150"
aria-label="Add company"
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</button> </button>

View File

@@ -1,5 +1,5 @@
import { ChevronsUpDown, Plus, Settings } from "lucide-react"; import { ChevronsUpDown, Plus, Settings } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { import {
DropdownMenu, DropdownMenu,

View File

@@ -12,15 +12,21 @@ interface CopyTextProps {
export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) { export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [label, setLabel] = useState(copiedLabel);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const handleClick = useCallback(() => { const handleClick = useCallback(async () => {
navigator.clipboard.writeText(text); try {
await navigator.clipboard.writeText(text);
setLabel(copiedLabel);
} catch {
setLabel("Copy failed");
}
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
setVisible(true); setVisible(true);
timerRef.current = setTimeout(() => setVisible(false), 1500); timerRef.current = setTimeout(() => setVisible(false), 1500);
}, [text]); }, [copiedLabel, text]);
return ( return (
<span className="relative inline-flex"> <span className="relative inline-flex">
@@ -36,12 +42,14 @@ export function CopyText({ text, children, className, copiedLabel = "Copied!" }:
{children ?? text} {children ?? text}
</button> </button>
<span <span
role="status"
aria-live="polite"
className={cn( className={cn(
"pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 rounded-md bg-foreground text-background px-2 py-1 text-xs whitespace-nowrap transition-opacity duration-300", "pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 rounded-md bg-foreground text-background px-2 py-1 text-xs whitespace-nowrap transition-opacity duration-300",
visible ? "opacity-100" : "opacity-0", visible ? "opacity-100" : "opacity-0",
)} )}
> >
{copiedLabel} {label}
</span> </span>
</span> </span>
); );

View File

@@ -1,5 +1,5 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
interface EntityRowProps { interface EntityRowProps {

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { Goal } from "@paperclip/shared"; import type { Goal } from "@paperclip/shared";
import { GOAL_STATUSES, GOAL_LEVELS } 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 { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";
import { formatDate } from "../lib/utils"; import { formatDate, cn, agentUrl } from "../lib/utils";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
interface GoalPropertiesProps { interface GoalPropertiesProps {
goal: Goal; goal: Goal;
@@ -128,7 +127,7 @@ export function GoalProperties({ goal, onUpdate }: GoalPropertiesProps) {
<PropertyRow label="Owner"> <PropertyRow label="Owner">
{ownerAgent ? ( {ownerAgent ? (
<Link <Link
to={`/agents/${ownerAgent.id}`} to={agentUrl(ownerAgent)}
className="text-sm hover:underline" className="text-sm hover:underline"
> >
{ownerAgent.name} {ownerAgent.name}

View File

@@ -1,5 +1,5 @@
import type { Goal } from "@paperclip/shared"; import type { Goal } from "@paperclip/shared";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";

View File

@@ -106,6 +106,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
align="start" align="start"
collisionPadding={16}
className="w-[min(20rem,calc(100vw-2rem))] p-1" className="w-[min(20rem,calc(100vw-2rem))] p-1"
onOpenAutoFocus={(event) => { onOpenAutoFocus={(event) => {
event.preventDefault(); event.preventDefault();
@@ -157,7 +158,10 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
} }
}} }}
/> />
<div className="max-h-56 overflow-y-auto overscroll-contain py-1"> <div
className="max-h-56 overflow-y-auto overscroll-contain py-1 touch-pan-y"
style={{ WebkitOverflowScrolling: "touch" }}
>
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<p className="px-2 py-2 text-xs text-muted-foreground">{emptyMessage}</p> <p className="px-2 py-2 text-xs text-muted-foreground">{emptyMessage}</p>
) : ( ) : (
@@ -169,7 +173,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
key={option.id || "__none__"} key={option.id || "__none__"}
type="button" type="button"
className={cn( className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm", "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm touch-pan-y",
isHighlighted && "bg-accent", isHighlighted && "bg-accent",
)} )}
onMouseEnter={() => setHighlightedIndex(index)} onMouseEnter={() => setHighlightedIndex(index)}

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import type { Issue } from "@paperclip/shared"; import type { Issue } from "@paperclip/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@@ -12,7 +12,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon"; import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { formatDate, cn } from "../lib/utils"; import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 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); const project = orderedProjects.find((p) => p.id === id);
return project?.name ?? id.slice(0, 8); 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 const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId) ? agents?.find((a) => a.id === issue.assigneeAgentId)
@@ -283,7 +288,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
} }
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
{createLabel.isPending ? "Creating..." : "Create label"} {createLabel.isPending ? "Creating" : "Create label"}
</button> </button>
</div> </div>
</> </>
@@ -482,7 +487,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
popoverClassName="w-fit min-w-[11rem]" popoverClassName="w-fit min-w-[11rem]"
extra={issue.projectId ? ( extra={issue.projectId ? (
<Link <Link
to={`/projects/${issue.projectId}`} to={projectLink(issue.projectId)!}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground" className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { import {
DndContext, DndContext,
DragOverlay, DragOverlay,

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState, type UIEvent } from "react"; import { useCallback, useEffect, useRef, useState, type UIEvent } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Sun } from "lucide-react"; 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 { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarNavItem } from "./SidebarNavItem";
@@ -31,8 +31,11 @@ export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog(); const { openNewIssue, openOnboarding } = useDialog();
const { panelContent, closePanel } = usePanel(); const { panelContent, closePanel } = usePanel();
const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany(); const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate();
const location = useLocation();
const onboardingTriggered = useRef(false); const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0); const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true); const [mobileNavVisible, setMobileNavVisible] = useState(true);
@@ -52,6 +55,40 @@ export function Layout() {
} }
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]); }, [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(() => { const togglePanel = useCallback(() => {
if (panelContent) closePanel(); if (panelContent) closePanel();
}, [panelContent, closePanel]); }, [panelContent, closePanel]);
@@ -151,11 +188,19 @@ export function Layout() {
return ( return (
<div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]"> <div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to Main Content
</a>
{/* Mobile backdrop */} {/* Mobile backdrop */}
{isMobile && sidebarOpen && ( {isMobile && sidebarOpen && (
<div <button
type="button"
className="fixed inset-0 z-40 bg-black/50" className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/> />
)} )}
@@ -163,7 +208,7 @@ export function Layout() {
{isMobile ? ( {isMobile ? (
<div <div
className={cn( className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-200 ease-in-out", "fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full" sidebarOpen ? "translate-x-0" : "-translate-x-full"
)} )}
> >
@@ -199,7 +244,7 @@ export function Layout() {
<CompanyRail /> <CompanyRail />
<div <div
className={cn( className={cn(
"overflow-hidden transition-all duration-200 ease-in-out", "overflow-hidden transition-[width] duration-100 ease-out",
sidebarOpen ? "w-60" : "w-0" sidebarOpen ? "w-60" : "w-0"
)} )}
> >
@@ -235,6 +280,8 @@ export function Layout() {
<BreadcrumbBar /> <BreadcrumbBar />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<main <main
id="main-content"
tabIndex={-1}
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")} className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
onScroll={handleMainScroll} onScroll={handleMainScroll}
> >

View File

@@ -1,14 +1,15 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; 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 { useQuery, useQueryClient } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclip/shared"; import type { LiveEvent } from "@paperclip/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { getUIAdapter } from "../adapters"; import { getUIAdapter } from "../adapters";
import type { TranscriptEntry } from "../adapters"; import type { TranscriptEntry } from "../adapters";
import { queryKeys } from "../lib/queryKeys"; 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 { ExternalLink, Square } from "lucide-react";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { StatusBadge } from "./StatusBadge";
interface LiveRunWidgetProps { interface LiveRunWidgetProps {
issueId: string; issueId: string;
@@ -311,55 +312,54 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
if (runs.length === 0 && feed.length === 0) return null; if (runs.length === 0 && feed.length === 0) return null;
const recent = feed.slice(-25); 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 ( return (
<div className="rounded-lg border border-cyan-500/30 bg-background/80 overflow-hidden shadow-[0_0_12px_rgba(6,182,212,0.08)]"> <div className="rounded-lg border border-cyan-500/30 bg-background/80 overflow-hidden shadow-[0_0_12px_rgba(6,182,212,0.08)]">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50"> {runs.length > 0 ? (
<div className="flex items-center gap-2"> runs.map((run) => (
{runs.length > 0 && ( <div key={run.id} className="px-3 py-2 border-b border-border/50">
<span className="relative flex h-2 w-2"> <div className="flex items-center justify-between mb-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" /> <Link to={`/agents/${run.agentId}`} className="hover:underline">
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" /> <Identity name={run.agentName} size="sm" />
</span> </Link>
)} <span className="text-xs text-muted-foreground">
<span className="text-xs font-medium"> {formatDateTime(run.startedAt ?? run.createdAt)}
{runs.length > 0 ? `Live issue runs (${runs.length})` : "Recent run updates"} </span>
</span> </div>
</div> <div className="flex items-center gap-2 text-xs">
{headerRun && ( <span className="text-muted-foreground">Run</span>
<div className="flex items-center gap-2"> <Link
{runs.length > 0 && ( to={`/agents/${run.agentId}/runs/${run.id}`}
<button className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
onClick={() => handleCancelRun(headerRun.id)}
disabled={cancellingRunIds.has(headerRun.id)}
className="inline-flex items-center gap-1 text-[10px] text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
> >
<Square className="h-2 w-2" fill="currentColor" /> {run.id.slice(0, 8)}
{cancellingRunIds.has(headerRun.id) ? "Stopping…" : "Stop"} </Link>
</button> <StatusBadge status={run.status} />
)} <div className="ml-auto flex items-center gap-2">
<Link <button
to={`/agents/${headerRun.agentId}/runs/${headerRun.id}`} onClick={() => handleCancelRun(run.id)}
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200" disabled={cancellingRunIds.has(run.id)}
> className="inline-flex items-center gap-1 text-[10px] text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
Open run >
<ExternalLink className="h-2.5 w-2.5" /> <Square className="h-2 w-2" fill="currentColor" />
</Link> {cancellingRunIds.has(run.id) ? "Stopping…" : "Stop"}
</button>
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
>
Open run
<ExternalLink className="h-2.5 w-2.5" />
</Link>
</div>
</div>
</div> </div>
)} ))
</div> ) : (
<div className="flex items-center px-3 py-2 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">Recent run updates</span>
</div>
)}
<div ref={bodyRef} className="max-h-[220px] overflow-y-auto p-2 font-mono text-[11px] space-y-1"> <div ref={bodyRef} className="max-h-[220px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
{recent.length === 0 && ( {recent.length === 0 && (
@@ -390,21 +390,6 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
))} ))}
</div> </div>
{runs.length > 1 && (
<div className="border-t border-border/50 px-3 py-2 flex flex-wrap gap-2">
{runs.map((run) => (
<div key={run.id} className="inline-flex items-center gap-1.5">
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
>
<Identity name={run.agentName} size="sm" /> {run.id.slice(0, 8)}
<ExternalLink className="h-2.5 w-2.5" />
</Link>
</div>
))}
</div>
)}
</div> </div>
); );
} }

View File

@@ -10,9 +10,11 @@ import {
type DragEvent, type DragEvent,
} from "react"; } from "react";
import { import {
CodeMirrorEditor,
MDXEditor, MDXEditor,
codeBlockPlugin, codeBlockPlugin,
codeMirrorPlugin, codeMirrorPlugin,
type CodeBlockEditorDescriptor,
type MDXEditorMethods, type MDXEditorMethods,
headingsPlugin, headingsPlugin,
imagePlugin, imagePlugin,
@@ -90,6 +92,14 @@ const CODE_BLOCK_LANGUAGES: Record<string, string> = {
yml: "YAML", 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 { function detectMention(container: HTMLElement): MentionState | null {
const sel = window.getSelection(); const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
@@ -247,7 +257,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
linkPlugin(), linkPlugin(),
linkDialogPlugin(), linkDialogPlugin(),
thematicBreakPlugin(), thematicBreakPlugin(),
codeBlockPlugin(), codeBlockPlugin({
defaultCodeBlockLanguage: "txt",
codeBlockEditorDescriptors: [FALLBACK_CODE_BLOCK_DESCRIPTOR],
}),
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }), codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
markdownShortcutPlugin(), markdownShortcutPlugin(),
]; ];

View File

@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
interface MetricCardProps { interface MetricCardProps {
icon: LucideIcon; icon: LucideIcon;

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { NavLink, useLocation } from "react-router-dom"; import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
House, House,
@@ -75,7 +75,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
{items.map((item) => { {items.map((item) => {
if (item.type === "action") { if (item.type === "action") {
const Icon = item.icon; const Icon = item.icon;
const active = location.pathname.startsWith("/issues/new"); const active = /\/issues\/new(?:\/|$)/.test(location.pathname);
return ( return (
<button <button
key={item.label} key={item.label}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "@/lib/router";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@@ -22,7 +22,7 @@ import {
Shield, Shield,
User, User,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils"; import { cn, agentUrl } from "../lib/utils";
import { roleLabels } from "./agent-config-primitives"; import { roleLabels } from "./agent-config-primitives";
import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm"; import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm";
import { defaultCreateValues } from "./agent-config-defaults"; import { defaultCreateValues } from "./agent-config-defaults";
@@ -80,7 +80,7 @@ export function NewAgentDialog() {
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
reset(); reset();
closeNewAgent(); closeNewAgent();
navigate(`/agents/${result.agent.id}`); navigate(agentUrl(result.agent));
}, },
}); });
@@ -286,7 +286,7 @@ export function NewAgentDialog() {
disabled={!name.trim() || createAgent.isPending} disabled={!name.trim() || createAgent.isPending}
onClick={handleSubmit} onClick={handleSubmit}
> >
{createAgent.isPending ? "Creating..." : "Create agent"} {createAgent.isPending ? "Creating" : "Create agent"}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -273,7 +273,7 @@ export function NewGoalDialog() {
disabled={!title.trim() || createGoal.isPending} disabled={!title.trim() || createGoal.isPending}
onClick={handleSubmit} onClick={handleSubmit}
> >
{createGoal.isPending ? "Creating..." : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"} {createGoal.isPending ? "Creating" : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -468,7 +468,7 @@ export function NewProjectDialog() {
disabled={!name.trim() || createProject.isPending} disabled={!name.trim() || createProject.isPending}
onClick={handleSubmit} onClick={handleSubmit}
> >
{createProject.isPending ? "Creating..." : "Create project"} {createProject.isPending ? "Creating" : "Create project"}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -1,26 +1,160 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
interface PageSkeletonProps { interface PageSkeletonProps {
variant?: "list" | "detail"; variant?:
| "list"
| "issues-list"
| "detail"
| "dashboard"
| "approvals"
| "costs"
| "inbox"
| "org-chart";
} }
export function PageSkeleton({ variant = "list" }: PageSkeletonProps) { export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
if (variant === "dashboard") {
return (
<div className="space-y-6">
<Skeleton className="h-32 w-full border border-border" />
<div className="grid grid-cols-2 gap-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-44 w-full" />
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-72 w-full" />
<Skeleton className="h-72 w-full" />
</div>
</div>
);
}
if (variant === "approvals") {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-44" />
</div>
<div className="grid gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full" />
))}
</div>
</div>
);
}
if (variant === "costs") {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-28" />
))}
</div>
<Skeleton className="h-40 w-full" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-72 w-full" />
<Skeleton className="h-72 w-full" />
</div>
</div>
);
}
if (variant === "inbox") {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-56" />
<Skeleton className="h-8 w-40" />
</div>
<div className="space-y-5">
{Array.from({ length: 3 }).map((_, section) => (
<div key={section} className="space-y-2">
<Skeleton className="h-4 w-40" />
<div className="space-y-1 border border-border">
{Array.from({ length: 3 }).map((_, row) => (
<Skeleton key={row} className="h-14 w-full rounded-none" />
))}
</div>
</div>
))}
</div>
</div>
);
}
if (variant === "org-chart") {
return (
<div className="space-y-4">
<Skeleton className="h-[calc(100vh-4rem)] w-full rounded-lg border border-border" />
</div>
);
}
if (variant === "detail") { if (variant === "detail") {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-3">
<Skeleton className="h-4 w-24" /> <Skeleton className="h-3 w-64" />
<Skeleton className="h-8 w-64" /> <div className="flex items-center gap-2">
<Skeleton className="h-4 w-full max-w-md" /> <Skeleton className="h-6 w-6" />
<Skeleton className="h-6 w-6" />
<Skeleton className="h-7 w-48" />
</div>
<Skeleton className="h-4 w-40" />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
<Skeleton className="h-32 w-full" /> <Skeleton className="h-32 w-full" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-4 w-32" /> <div className="flex items-center gap-2">
<Skeleton className="h-20 w-full" /> <Skeleton className="h-8 w-24" />
<Skeleton className="h-20 w-full" /> <Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
</div>
);
}
if (variant === "issues-list") {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-64" />
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-24" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<div className="space-y-1">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full rounded-none" />
))}
</div>
</div> </div>
</div> </div>
); );
@@ -28,14 +162,17 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-6 w-32" /> <Skeleton className="h-9 w-44" />
<Skeleton className="h-8 w-24" /> <div className="flex items-center gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-24" />
</div>
</div> </div>
<Skeleton className="h-10 w-full max-w-sm" />
<div className="space-y-1"> <div className="space-y-1">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" /> <Skeleton key={i} className="h-11 w-full rounded-none" />
))} ))}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclip/shared"; import type { Project } from "@paperclip/shared";
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";

View File

@@ -21,7 +21,6 @@ import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
export function Sidebar() { export function Sidebar() {
const { openNewIssue } = useDialog(); const { openNewIssue } = useDialog();
@@ -66,45 +65,43 @@ export function Sidebar() {
</Button> </Button>
</div> </div>
<ScrollArea className="flex-1"> <nav className="flex-1 min-h-0 overflow-y-auto scrollbar-none flex flex-col gap-4 px-3 py-2">
<nav className="flex flex-col gap-4 px-3 py-2"> <div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-0.5"> {/* New Issue button aligned with nav items */}
{/* New Issue button aligned with nav items */} <button
<button onClick={() => openNewIssue()}
onClick={() => openNewIssue()} className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors" >
> <SquarePen className="h-4 w-4 shrink-0" />
<SquarePen className="h-4 w-4 shrink-0" /> <span className="truncate">New Issue</span>
<span className="truncate">New Issue</span> </button>
</button> <SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} /> <SidebarNavItem
<SidebarNavItem to="/inbox"
to="/inbox" label="Inbox"
label="Inbox" icon={Inbox}
icon={Inbox} badge={sidebarBadges?.inbox}
badge={sidebarBadges?.inbox} badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"} alert={(sidebarBadges?.failedRuns ?? 0) > 0}
alert={(sidebarBadges?.failedRuns ?? 0) > 0} />
/> </div>
</div>
<SidebarSection label="Work"> <SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} /> <SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} /> <SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection> </SidebarSection>
<SidebarProjects /> <SidebarProjects />
<SidebarAgents /> <SidebarAgents />
<SidebarSection label="Company"> <SidebarSection label="Company">
<SidebarNavItem to="/org" label="Org" icon={Network} /> <SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} /> <SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} /> <SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} /> <SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection> </SidebarSection>
</nav> </nav>
</ScrollArea>
</aside> </aside>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; 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 { useQuery } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@@ -7,7 +7,7 @@ import { useSidebar } from "../context/SidebarContext";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils"; import { cn, agentRouteRef, agentUrl } from "../lib/utils";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
import { import {
Collapsible, Collapsible,
@@ -71,7 +71,7 @@ export function SidebarAgents() {
return sortByHierarchy(filtered); return sortByHierarchy(filtered);
}, [agents]); }, [agents]);
const agentMatch = location.pathname.match(/^\/agents\/([^/]+)/); const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)/);
const activeAgentId = agentMatch?.[1] ?? null; const activeAgentId = agentMatch?.[1] ?? null;
return ( return (
@@ -99,13 +99,13 @@ export function SidebarAgents() {
return ( return (
<NavLink <NavLink
key={agent.id} key={agent.id}
to={`/agents/${agent.id}`} to={agentUrl(agent)}
onClick={() => { onClick={() => {
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
}} }}
className={cn( className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", "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" ? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground" : "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
)} )}

View File

@@ -1,4 +1,4 @@
import { NavLink } from "react-router-dom"; import { NavLink } from "@/lib/router";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react"; 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 { useQuery } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react"; import { ChevronRight, Plus } from "lucide-react";
import { import {
@@ -18,7 +18,7 @@ import { useSidebar } from "../context/SidebarContext";
import { authApi } from "../api/auth"; import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils"; import { cn, projectRouteRef } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { import {
Collapsible, Collapsible,
@@ -28,12 +28,12 @@ import {
import type { Project } from "@paperclip/shared"; import type { Project } from "@paperclip/shared";
function SortableProjectItem({ function SortableProjectItem({
activeProjectId, activeProjectRef,
isMobile, isMobile,
project, project,
setSidebarOpen, setSidebarOpen,
}: { }: {
activeProjectId: string | null; activeProjectRef: string | null;
isMobile: boolean; isMobile: boolean;
project: Project; project: Project;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
@@ -47,6 +47,8 @@ function SortableProjectItem({
isDragging, isDragging,
} = useSortable({ id: project.id }); } = useSortable({ id: project.id });
const routeRef = projectRouteRef(project);
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
@@ -60,13 +62,13 @@ function SortableProjectItem({
{...listeners} {...listeners}
> >
<NavLink <NavLink
to={`/projects/${project.id}/issues`} to={`/projects/${routeRef}/issues`}
onClick={() => { onClick={() => {
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
}} }}
className={cn( className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", "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" ? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground", : "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)} )}
@@ -110,8 +112,8 @@ export function SidebarProjects() {
userId: currentUserId, userId: currentUserId,
}); });
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/); const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectId = projectMatch?.[1] ?? null; const activeProjectRef = projectMatch?.[1] ?? null;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, activationConstraint: { distance: 8 },
@@ -175,7 +177,7 @@ export function SidebarProjects() {
{orderedProjects.map((project: Project) => ( {orderedProjects.map((project: Project) => (
<SortableProjectItem <SortableProjectItem
key={project.id} key={project.id}
activeProjectId={activeProjectId} activeProjectRef={activeProjectRef}
isMobile={isMobile} isMobile={isMobile}
project={project} project={project}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useToast, type ToastItem, type ToastTone } from "../context/ToastContext"; import { useToast, type ToastItem, type ToastTone } from "../context/ToastContext";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -35,7 +35,7 @@ function AnimatedToast({
return ( return (
<li <li
className={cn( className={cn(
"pointer-events-auto rounded-sm border shadow-lg backdrop-blur-xl transition-all duration-300 ease-out", "pointer-events-auto rounded-sm border shadow-lg backdrop-blur-xl transition-[transform,opacity] duration-200 ease-out",
visible visible
? "translate-y-0 opacity-100" ? "translate-y-0 opacity-100"
: "translate-y-3 opacity-0", : "translate-y-3 opacity-0",

View File

@@ -4,6 +4,15 @@ import {
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react"; import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -23,10 +32,9 @@ export const help: Record<string, string> = {
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.", 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.", maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
command: "The command to execute (e.g. node, python).", 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.", args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, 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.", 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 (
<>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => setOpen(true)}
>
Choose
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Specify path manually</DialogTitle>
<DialogDescription>
Browser security blocks apps from reading full local paths via a file picker.
Copy the absolute path and paste it into the input.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<section className="space-y-1.5">
<p className="font-medium">macOS (Finder)</p>
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
<li>Find the folder in Finder.</li>
<li>Hold <kbd>Option</kbd> and right-click the folder.</li>
<li>Click "Copy &lt;folder name&gt; as Pathname".</li>
<li>Paste the result into the path input.</li>
</ol>
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
/Users/yourname/Documents/project
</p>
</section>
<section className="space-y-1.5">
<p className="font-medium">Windows (File Explorer)</p>
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
<li>Find the folder in File Explorer.</li>
<li>Hold <kbd>Shift</kbd> and right-click the folder.</li>
<li>Click "Copy as path".</li>
<li>Paste the result into the path input.</li>
</ol>
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
C:\Users\yourname\Documents\project
</p>
</section>
<section className="space-y-1.5">
<p className="font-medium">Terminal fallback (macOS/Linux)</p>
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
<li>Run <code>cd /path/to/folder</code>.</li>
<li>Run <code>pwd</code>.</li>
<li>Copy the output and paste it into the path input.</li>
</ol>
</section>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/**
* 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 (
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 shrink-0">
<label className="text-xs text-muted-foreground">{label}</label>
{hint && <HintIcon text={hint} />}
</div>
<div className="w-24 ml-auto">{children}</div>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
@@ -21,13 +21,13 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { 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", 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", 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-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8", "icon-sm": "size-9",
"icon-lg": "size-10", "icon-lg": "size-10",
}, },
}, },

View File

@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn("bg-accent/75 rounded-md", className)}
{...props} {...props}
/> />
) )

View File

@@ -62,7 +62,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground", "data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100", "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",

View File

@@ -13,13 +13,17 @@ import { companiesApi } from "../api/companies";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
type CompanySelectionOptions = { source?: CompanySelectionSource };
interface CompanyContextValue { interface CompanyContextValue {
companies: Company[]; companies: Company[];
selectedCompanyId: string | null; selectedCompanyId: string | null;
selectedCompany: Company | null; selectedCompany: Company | null;
selectionSource: CompanySelectionSource;
loading: boolean; loading: boolean;
error: Error | null; error: Error | null;
setSelectedCompanyId: (companyId: string) => void; setSelectedCompanyId: (companyId: string, options?: CompanySelectionOptions) => void;
reloadCompanies: () => Promise<void>; reloadCompanies: () => Promise<void>;
createCompany: (data: { createCompany: (data: {
name: string; name: string;
@@ -34,24 +38,8 @@ const CompanyContext = createContext<CompanyContextValue | null>(null);
export function CompanyProvider({ children }: { children: ReactNode }) { export function CompanyProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>( const [selectionSource, setSelectionSource] = useState<CompanySelectionSource>("bootstrap");
() => { const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(() => localStorage.getItem(STORAGE_KEY));
// 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 { data: companies = [], isLoading, error } = useQuery({ const { data: companies = [], isLoading, error } = useQuery({
queryKey: queryKeys.companies.all, queryKey: queryKeys.companies.all,
@@ -83,11 +71,13 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
const next = selectableCompanies[0]!.id; const next = selectableCompanies[0]!.id;
setSelectedCompanyIdState(next); setSelectedCompanyIdState(next);
setSelectionSource("bootstrap");
localStorage.setItem(STORAGE_KEY, next); localStorage.setItem(STORAGE_KEY, next);
}, [companies, selectedCompanyId, sidebarCompanies]); }, [companies, selectedCompanyId, sidebarCompanies]);
const setSelectedCompanyId = useCallback((companyId: string) => { const setSelectedCompanyId = useCallback((companyId: string, options?: CompanySelectionOptions) => {
setSelectedCompanyIdState(companyId); setSelectedCompanyIdState(companyId);
setSelectionSource(options?.source ?? "manual");
localStorage.setItem(STORAGE_KEY, companyId); localStorage.setItem(STORAGE_KEY, companyId);
}, []); }, []);
@@ -121,6 +111,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
companies, companies,
selectedCompanyId, selectedCompanyId,
selectedCompany, selectedCompany,
selectionSource,
loading: isLoading, loading: isLoading,
error: error as Error | null, error: error as Error | null,
setSelectedCompanyId, setSelectedCompanyId,
@@ -131,6 +122,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
companies, companies,
selectedCompanyId, selectedCompanyId,
selectedCompany, selectedCompany,
selectionSource,
isLoading, isLoading,
error, error,
setSelectedCompanyId, setSelectedCompanyId,

View File

@@ -125,7 +125,7 @@ function resolveIssueToastContext(
} }
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); 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"]); const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
function describeIssueUpdate(details: Record<string, unknown> | null): string | null { function describeIssueUpdate(details: Record<string, unknown> | null): string | null {
@@ -178,6 +178,10 @@ function buildActivityToast(
} }
if (action === "issue.updated") { 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 changeDesc = describeIssueUpdate(details);
const body = changeDesc const body = changeDesc
? issue.title ? issue.title
@@ -197,13 +201,26 @@ function buildActivityToast(
const commentId = readString(details?.commentId); const commentId = readString(details?.commentId);
const bodySnippet = readString(details?.bodySnippet); 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 { return {
title: `${actor} commented on ${issue.ref}`, title,
body: bodySnippet body: body ? truncate(body, 96) : undefined,
? truncate(bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " "), 96)
: issue.title
? truncate(issue.title, 96)
: undefined,
tone: "info", tone: "info",
action: { label: `View ${issue.ref}`, href: issue.href }, action: { label: `View ${issue.ref}`, href: issue.href },
dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`, dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`,
@@ -220,14 +237,12 @@ function buildAgentStatusToast(
const status = readString(payload.status); const status = readString(payload.status);
if (!agentId || !status || !AGENT_TOAST_STATUSES.has(status)) return null; 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 name = nameOf(agentId) ?? `Agent ${shortId(agentId)}`;
const title = const title =
status === "running" status === "running"
? `${name} started` ? `${name} started`
: status === "idle" : `${name} errored`;
? `${name} is idle`
: `${name} errored`;
const agents = queryClient.getQueryData<Agent[]>(queryKeys.agents.list(companyId)); const agents = queryClient.getQueryData<Agent[]>(queryKeys.agents.list(companyId));
const agent = agents?.find((a) => a.id === agentId); const agent = agents?.find((a) => a.id === agentId);

View File

@@ -1,8 +1,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "@/lib/router";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { toCompanyRelativePath } from "../lib/company-routes";
const STORAGE_KEY = "paperclip.companyPaths"; const STORAGE_KEY = "paperclip.companyPaths";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
function getCompanyPaths(): Record<string, string> { function getCompanyPaths(): Record<string, string> {
try { try {
@@ -20,12 +22,21 @@ function saveCompanyPath(companyId: string, path: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths)); 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. * 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. * Falls back to /dashboard if no page was previously visited for a company.
*/ */
export function useCompanyPageMemory() { export function useCompanyPageMemory() {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId, selectedCompany, selectionSource } = useCompany();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const prevCompanyId = useRef<string | null>(selectedCompanyId); const prevCompanyId = useRef<string | null>(selectedCompanyId);
@@ -36,8 +47,9 @@ export function useCompanyPageMemory() {
const fullPath = location.pathname + location.search; const fullPath = location.pathname + location.search;
useEffect(() => { useEffect(() => {
const companyId = prevCompanyId.current; const companyId = prevCompanyId.current;
if (companyId) { const relativePath = toCompanyRelativePath(fullPath);
saveCompanyPath(companyId, fullPath); if (companyId && isRememberableCompanyPath(relativePath)) {
saveCompanyPath(companyId, relativePath);
} }
}, [fullPath]); }, [fullPath]);
@@ -49,10 +61,14 @@ export function useCompanyPageMemory() {
prevCompanyId.current !== null && prevCompanyId.current !== null &&
selectedCompanyId !== prevCompanyId.current selectedCompanyId !== prevCompanyId.current
) { ) {
const paths = getCompanyPaths(); if (selectionSource !== "route_sync" && selectedCompany) {
const savedPath = paths[selectedCompanyId]; const paths = getCompanyPaths();
navigate(savedPath || "/dashboard", { replace: true }); 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; prevCompanyId.current = selectedCompanyId;
}, [selectedCompanyId, navigate]); }, [selectedCompany, selectedCompanyId, selectionSource, navigate]);
} }

View File

@@ -120,12 +120,18 @@
} }
html { html {
height: 100%; height: 100%;
-webkit-tap-highlight-color: color-mix(in oklab, var(--foreground) 20%, transparent);
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
h1,
h2,
h3 {
text-wrap: balance;
}
/* Prevent double-tap-to-zoom on interactive elements for mobile */ /* Prevent double-tap-to-zoom on interactive elements for mobile */
a, a,
button, 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 mode scrollbars */
.dark { .dark {
color-scheme: dark; color-scheme: dark;

View File

@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { deriveAgentUrlKey, deriveProjectUrlKey } from "@paperclip/shared";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -51,3 +52,23 @@ export function formatTokens(n: number): string {
export function issueUrl(issue: { id: string; identifier?: string | null }): string { export function issueUrl(issue: { id: string; identifier?: string | null }): string {
return `/issues/${issue.identifier ?? issue.id}`; 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)}`;
}

View File

@@ -1,6 +1,6 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App"; import { App } from "./App";
import { CompanyProvider } from "./context/CompanyContext"; import { CompanyProvider } from "./context/CompanyContext";

View File

@@ -10,6 +10,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { ActivityRow } from "../components/ActivityRow"; import { ActivityRow } from "../components/ActivityRow";
import { PageSkeleton } from "../components/PageSkeleton";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -84,6 +85,10 @@ export function Activity() {
return <EmptyState icon={History} message="Select a company to view activity." />; return <EmptyState icon={History} message="Select a company to view activity." />;
} }
if (isLoading) {
return <PageSkeleton variant="list" />;
}
const filtered = const filtered =
data && filter !== "all" data && filter !== "all"
? data.filter((e) => e.entityType === filter) ? data.filter((e) => e.entityType === filter)
@@ -111,7 +116,6 @@ export function Activity() {
</Select> </Select>
</div> </div>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{filtered && filtered.length === 0 && ( {filtered && filtered.length === 0 && (

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react"; 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
@@ -22,6 +22,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText"; import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -54,7 +55,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; 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<string, { icon: typeof CheckCircle2; color: string }> = { const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" }, succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
@@ -223,8 +225,13 @@ function asNonEmptyString(value: unknown): string | null {
} }
export function AgentDetail() { export function AgentDetail() {
const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>(); const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{
const { selectedCompanyId } = useCompany(); companyPrefix?: string;
agentId: string;
tab?: string;
runId?: string;
}>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel(); const { closePanel } = usePanel();
const { openNewIssue } = useDialog(); const { openNewIssue } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
@@ -238,68 +245,101 @@ export function AgentDetail() {
const saveConfigActionRef = useRef<(() => void) | null>(null); const saveConfigActionRef = useRef<(() => void) | null>(null);
const cancelConfigActionRef = useRef<(() => void) | null>(null); const cancelConfigActionRef = useRef<(() => void) | null>(null);
const { isMobile } = useSidebar(); 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 setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
const { data: agent, isLoading, error } = useQuery({ const { data: agent, isLoading, error } = useQuery({
queryKey: queryKeys.agents.detail(agentId!), queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
queryFn: () => agentsApi.get(agentId!), queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
enabled: !!agentId, enabled: canFetchAgent,
}); });
const resolvedCompanyId = agent?.companyId ?? selectedCompanyId;
const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef;
const agentLookupRef = agent?.id ?? routeAgentRef;
const { data: runtimeState } = useQuery({ const { data: runtimeState } = useQuery({
queryKey: queryKeys.agents.runtimeState(agentId!), queryKey: queryKeys.agents.runtimeState(agentLookupRef),
queryFn: () => agentsApi.runtimeState(agentId!), queryFn: () => agentsApi.runtimeState(agentLookupRef, resolvedCompanyId ?? undefined),
enabled: !!agentId, enabled: Boolean(agentLookupRef),
}); });
const { data: heartbeats } = useQuery({ const { data: heartbeats } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId), queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined),
queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId), queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined),
enabled: !!selectedCompanyId && !!agentId, enabled: !!resolvedCompanyId && !!agent?.id,
}); });
const { data: allIssues } = useQuery({ const { data: allIssues } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!), queryKey: queryKeys.issues.list(resolvedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!), queryFn: () => issuesApi.list(resolvedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!resolvedCompanyId,
}); });
const { data: allAgents } = useQuery({ const { data: allAgents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(resolvedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!), queryFn: () => agentsApi.list(resolvedCompanyId!),
enabled: !!selectedCompanyId, 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 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( const mobileLiveRun = useMemo(
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null, () => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
[heartbeats], [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({ const agentAction = useMutation({
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => { 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) { switch (action) {
case "invoke": return agentsApi.invoke(agentId); case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
case "pause": return agentsApi.pause(agentId); case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
case "resume": return agentsApi.resume(agentId); case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
case "terminate": return agentsApi.terminate(agentId); case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
} }
}, },
onSuccess: (data, action) => { onSuccess: (data, action) => {
setActionError(null); setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(selectedCompanyId, agentId!) }); 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) { 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) => { onError: (err) => {
@@ -308,21 +348,23 @@ export function AgentDetail() {
}); });
const updateIcon = useMutation({ const updateIcon = useMutation({
mutationFn: (icon: string) => agentsApi.update(agentId!, { icon }), mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
} }
}, },
}); });
const resetTaskSession = useMutation({ const resetTaskSession = useMutation({
mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey), mutationFn: (taskKey: string | null) =>
agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined),
onSuccess: () => { onSuccess: () => {
setActionError(null); setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
}, },
onError: (err) => { onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to reset session"); setActionError(err instanceof Error ? err.message : "Failed to reset session");
@@ -331,12 +373,13 @@ export function AgentDetail() {
const updatePermissions = useMutation({ const updatePermissions = useMutation({
mutationFn: (canCreateAgents: boolean) => mutationFn: (canCreateAgents: boolean) =>
agentsApi.updatePermissions(agentId!, { canCreateAgents }), agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined),
onSuccess: () => { onSuccess: () => {
setActionError(null); setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
} }
}, },
onError: (err) => { onError: (err) => {
@@ -348,13 +391,13 @@ export function AgentDetail() {
const crumbs: { label: string; href?: string }[] = [ const crumbs: { label: string; href?: string }[] = [
{ label: "Agents", href: "/agents" }, { label: "Agents", href: "/agents" },
]; ];
const agentName = agent?.name ?? agentId ?? "Agent"; const agentName = agent?.name ?? routeAgentRef ?? "Agent";
if (activeView === "overview" && !urlRunId) { if (activeView === "overview" && !urlRunId) {
crumbs.push({ label: agentName }); crumbs.push({ label: agentName });
} else { } else {
crumbs.push({ label: agentName, href: `/agents/${agentId}` }); crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}` });
if (urlRunId) { 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)}` }); crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
} else if (activeView === "configure") { } else if (activeView === "configure") {
crumbs.push({ label: "Configure" }); crumbs.push({ label: "Configure" });
@@ -363,7 +406,7 @@ export function AgentDetail() {
} }
} }
setBreadcrumbs(crumbs); setBreadcrumbs(crumbs);
}, [setBreadcrumbs, agent, agentId, activeView, urlRunId]); }, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]);
useEffect(() => { useEffect(() => {
closePanel(); closePanel();
@@ -378,7 +421,7 @@ export function AgentDetail() {
}, [configDirty]), }, [configDirty]),
); );
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>; if (isLoading) return <PageSkeleton variant="detail" />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>; if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!agent) return null; if (!agent) return null;
const isPendingApproval = agent.status === "pending_approval"; const isPendingApproval = agent.status === "pending_approval";
@@ -409,7 +452,7 @@ export function AgentDetail() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => openNewIssue({ assigneeAgentId: agentId })} onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
> >
<Plus className="h-3.5 w-3.5 sm:mr-1" /> <Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Assign Task</span> <span className="hidden sm:inline">Assign Task</span>
@@ -447,7 +490,7 @@ export function AgentDetail() {
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span> <span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
{mobileLiveRun && ( {mobileLiveRun && (
<Link <Link
to={`/agents/${agent.id}/runs/${mobileLiveRun.id}`} to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline" className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
> >
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
@@ -466,6 +509,16 @@ export function AgentDetail() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end"> <PopoverContent className="w-44 p-1" align="end">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
navigate(`/agents/${canonicalAgentRef}/configure`);
setMoreOpen(false);
}}
>
<Settings className="h-3 w-3" />
Configure Agent
</button>
<button <button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50" className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => { onClick={() => {
@@ -532,7 +585,7 @@ export function AgentDetail() {
onClick={() => saveConfigActionRef.current?.()} onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving} disabled={configSaving}
> >
{configSaving ? "Saving..." : "Save"} {configSaving ? "Saving" : "Save"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -558,7 +611,7 @@ export function AgentDetail() {
onClick={() => saveConfigActionRef.current?.()} onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving} disabled={configSaving}
> >
{configSaving ? "Saving..." : "Save"} {configSaving ? "Saving" : "Save"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -573,14 +626,16 @@ export function AgentDetail() {
runtimeState={runtimeState} runtimeState={runtimeState}
reportsToAgent={reportsToAgent ?? null} reportsToAgent={reportsToAgent ?? null}
directReports={directReports} directReports={directReports}
agentId={agentId!} agentId={agent.id}
agentRouteId={canonicalAgentRef}
/> />
)} )}
{activeView === "configure" && ( {activeView === "configure" && (
<AgentConfigurePage <AgentConfigurePage
agent={agent} agent={agent}
agentId={agentId!} agentId={agent.id}
companyId={resolvedCompanyId ?? undefined}
onDirtyChange={setConfigDirty} onDirtyChange={setConfigDirty}
onSaveActionChange={setSaveConfigAction} onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction} onCancelActionChange={setCancelConfigAction}
@@ -592,8 +647,9 @@ export function AgentDetail() {
{activeView === "runs" && ( {activeView === "runs" && (
<RunsTab <RunsTab
runs={heartbeats ?? []} runs={heartbeats ?? []}
companyId={selectedCompanyId!} companyId={resolvedCompanyId!}
agentId={agentId!} agentId={agent.id}
agentRouteId={canonicalAgentRef}
selectedRunId={urlRunId ?? null} selectedRunId={urlRunId ?? null}
adapterType={agent.adapterType} adapterType={agent.adapterType}
/> />
@@ -631,7 +687,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex w-full items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-medium"> <h3 className="flex items-center gap-2 text-sm font-medium">
{isLive && ( {isLive && (
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
@@ -649,10 +705,13 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
</Link> </Link>
</div> </div>
<div className={cn( <Link
"border rounded-lg p-4 space-y-2", to={`/agents/${agentId}/runs/${run.id}`}
isLive ? "border-cyan-500/30 shadow-[0_0_12px_rgba(6,182,212,0.08)]" : "border-border" className={cn(
)}> "block border rounded-lg p-4 space-y-2 w-full no-underline transition-colors hover:bg-muted/50 cursor-pointer",
isLive ? "border-cyan-500/30 shadow-[0_0_12px_rgba(6,182,212,0.08)]" : "border-border"
)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusIcon className={cn("h-3.5 w-3.5", statusInfo.color, run.status === "running" && "animate-spin")} /> <StatusIcon className={cn("h-3.5 w-3.5", statusInfo.color, run.status === "running" && "animate-spin")} />
<StatusBadge status={run.status} /> <StatusBadge status={run.status} />
@@ -674,7 +733,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
<MarkdownBody className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{summary}</MarkdownBody> <MarkdownBody className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{summary}</MarkdownBody>
</div> </div>
)} )}
</div> </Link>
</div> </div>
); );
} }
@@ -689,6 +748,7 @@ function AgentOverview({
reportsToAgent, reportsToAgent,
directReports, directReports,
agentId, agentId,
agentRouteId,
}: { }: {
agent: Agent; agent: Agent;
runs: HeartbeatRun[]; runs: HeartbeatRun[];
@@ -697,11 +757,12 @@ function AgentOverview({
reportsToAgent: Agent | null; reportsToAgent: Agent | null;
directReports: Agent[]; directReports: Agent[];
agentId: string; agentId: string;
agentRouteId: string;
}) { }) {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Latest Run */} {/* Latest Run */}
<LatestRunCard runs={runs} agentId={agentId} /> <LatestRunCard runs={runs} agentId={agentRouteId} />
{/* Charts */} {/* Charts */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
@@ -758,7 +819,7 @@ function AgentOverview({
{/* Configuration Summary */} {/* Configuration Summary */}
<ConfigSummary <ConfigSummary
agent={agent} agent={agent}
agentId={agentId} agentRouteId={agentRouteId}
reportsToAgent={reportsToAgent} reportsToAgent={reportsToAgent}
directReports={directReports} directReports={directReports}
/> />
@@ -772,12 +833,12 @@ function AgentOverview({
function ConfigSummary({ function ConfigSummary({
agent, agent,
agentId, agentRouteId,
reportsToAgent, reportsToAgent,
directReports, directReports,
}: { }: {
agent: Agent; agent: Agent;
agentId: string; agentRouteId: string;
reportsToAgent: Agent | null; reportsToAgent: Agent | null;
directReports: Agent[]; directReports: Agent[];
}) { }) {
@@ -789,7 +850,7 @@ function ConfigSummary({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Configuration</h3> <h3 className="text-sm font-medium">Configuration</h3>
<Link <Link
to={`/agents/${agentId}/configure`} to={`/agents/${agentRouteId}/configure`}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
> >
<Settings className="h-3 w-3" /> <Settings className="h-3 w-3" />
@@ -835,7 +896,7 @@ function ConfigSummary({
<SummaryRow label="Reports to"> <SummaryRow label="Reports to">
{reportsToAgent ? ( {reportsToAgent ? (
<Link <Link
to={`/agents/${reportsToAgent.id}`} to={`/agents/${agentRouteRef(reportsToAgent)}`}
className="text-blue-600 hover:underline dark:text-blue-400" className="text-blue-600 hover:underline dark:text-blue-400"
> >
<Identity name={reportsToAgent.name} size="sm" /> <Identity name={reportsToAgent.name} size="sm" />
@@ -852,7 +913,7 @@ function ConfigSummary({
{directReports.map((r) => ( {directReports.map((r) => (
<Link <Link
key={r.id} key={r.id}
to={`/agents/${r.id}`} to={`/agents/${agentRouteRef(r)}`}
className="flex items-center gap-2 text-sm text-blue-600 hover:underline dark:text-blue-400" className="flex items-center gap-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
> >
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
@@ -966,6 +1027,7 @@ function CostsSection({
function AgentConfigurePage({ function AgentConfigurePage({
agent, agent,
agentId, agentId,
companyId,
onDirtyChange, onDirtyChange,
onSaveActionChange, onSaveActionChange,
onCancelActionChange, onCancelActionChange,
@@ -974,6 +1036,7 @@ function AgentConfigurePage({
}: { }: {
agent: Agent; agent: Agent;
agentId: string; agentId: string;
companyId?: string;
onDirtyChange: (dirty: boolean) => void; onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void; onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void;
@@ -985,11 +1048,11 @@ function AgentConfigurePage({
const { data: configRevisions } = useQuery({ const { data: configRevisions } = useQuery({
queryKey: queryKeys.agents.configRevisions(agent.id), queryKey: queryKeys.agents.configRevisions(agent.id),
queryFn: () => agentsApi.listConfigRevisions(agent.id), queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId),
}); });
const rollbackConfig = useMutation({ const rollbackConfig = useMutation({
mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId), mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
@@ -1005,10 +1068,11 @@ function AgentConfigurePage({
onCancelActionChange={onCancelActionChange} onCancelActionChange={onCancelActionChange}
onSavingChange={onSavingChange} onSavingChange={onSavingChange}
updatePermissions={updatePermissions} updatePermissions={updatePermissions}
companyId={companyId}
/> />
<div> <div>
<h3 className="text-sm font-medium mb-3">API Keys</h3> <h3 className="text-sm font-medium mb-3">API Keys</h3>
<KeysTab agentId={agentId} /> <KeysTab agentId={agentId} companyId={companyId} />
</div> </div>
{/* Configuration Revisions — collapsible at the bottom */} {/* Configuration Revisions — collapsible at the bottom */}
@@ -1069,6 +1133,7 @@ function AgentConfigurePage({
function ConfigurationTab({ function ConfigurationTab({
agent, agent,
companyId,
onDirtyChange, onDirtyChange,
onSaveActionChange, onSaveActionChange,
onCancelActionChange, onCancelActionChange,
@@ -1076,6 +1141,7 @@ function ConfigurationTab({
updatePermissions, updatePermissions,
}: { }: {
agent: Agent; agent: Agent;
companyId?: string;
onDirtyChange: (dirty: boolean) => void; onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void; onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void;
@@ -1090,7 +1156,7 @@ function ConfigurationTab({
}); });
const updateAgent = useMutation({ const updateAgent = useMutation({
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data), mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(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(); const { isMobile } = useSidebar();
if (runs.length === 0) { if (runs.length === 0) {
@@ -1212,20 +1292,20 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
return ( return (
<div className="space-y-3 min-w-0 overflow-x-hidden"> <div className="space-y-3 min-w-0 overflow-x-hidden">
<Link <Link
to={`/agents/${agentId}/runs`} to={`/agents/${agentRouteId}/runs`}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline" className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors no-underline"
> >
<ArrowLeft className="h-3.5 w-3.5" /> <ArrowLeft className="h-3.5 w-3.5" />
Back to runs Back to runs
</Link> </Link>
<RunDetail key={selectedRun.id} run={selectedRun} adapterType={adapterType} /> <RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
</div> </div>
); );
} }
return ( return (
<div className="border border-border rounded-lg overflow-x-hidden"> <div className="border border-border rounded-lg overflow-x-hidden">
{sorted.map((run) => ( {sorted.map((run) => (
<RunListItem key={run.id} run={run} isSelected={false} agentId={agentId} /> <RunListItem key={run.id} run={run} isSelected={false} agentId={agentRouteId} />
))} ))}
</div> </div>
); );
@@ -1241,7 +1321,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
)}> )}>
<div className="sticky top-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 2rem)" }}> <div className="sticky top-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 2rem)" }}>
{sorted.map((run) => ( {sorted.map((run) => (
<RunListItem key={run.id} run={run} isSelected={run.id === effectiveRunId} agentId={agentId} /> <RunListItem key={run.id} run={run} isSelected={run.id === effectiveRunId} agentId={agentRouteId} />
))} ))}
</div> </div>
</div> </div>
@@ -1249,7 +1329,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
{/* Right: run detail — natural height, page scrolls */} {/* Right: run detail — natural height, page scrolls */}
{selectedRun && ( {selectedRun && (
<div className="flex-1 min-w-0 pl-4"> <div className="flex-1 min-w-0 pl-4">
<RunDetail key={selectedRun.id} run={selectedRun} adapterType={adapterType} /> <RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
</div> </div>
)} )}
</div> </div>
@@ -1258,7 +1338,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
/* ---- Run Detail (expanded) ---- */ /* ---- 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 queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const metrics = runMetrics(run); const metrics = runMetrics(run);
@@ -1299,7 +1379,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
triggerDetail: "manual", triggerDetail: "manual",
reason: "resume_process_lost_run", reason: "resume_process_lost_run",
payload: resumePayload, payload: resumePayload,
}); }, run.companyId);
if (!("id" in result)) { if (!("id" in result)) {
throw new Error("Resume request was skipped because the agent is not currently invokable."); 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) => { onSuccess: (resumedRun) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); 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({ const clearSessionsForTouchedIssues = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (touchedIssueIds.length === 0) return 0; 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; return touchedIssueIds.length;
}, },
onSuccess: () => { onSuccess: () => {
@@ -1334,7 +1414,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
}); });
const runClaudeLogin = useMutation({ const runClaudeLogin = useMutation({
mutationFn: () => agentsApi.loginWithClaude(run.agentId), mutationFn: () => agentsApi.loginWithClaude(run.agentId, run.companyId),
onSuccess: (data) => { onSuccess: (data) => {
setClaudeLoginResult(data); setClaudeLoginResult(data);
}, },
@@ -1386,7 +1466,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
onClick={() => cancelRun.mutate()} onClick={() => cancelRun.mutate()}
disabled={cancelRun.isPending} disabled={cancelRun.isPending}
> >
{cancelRun.isPending ? "Cancelling..." : "Cancel"} {cancelRun.isPending ? "Cancelling" : "Cancel"}
</Button> </Button>
)} )}
{canResumeLostRun && ( {canResumeLostRun && (
@@ -1398,7 +1478,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
disabled={resumeRun.isPending} disabled={resumeRun.isPending}
> >
<RotateCcw className="h-3.5 w-3.5 mr-1" /> <RotateCcw className="h-3.5 w-3.5 mr-1" />
{resumeRun.isPending ? "Resuming..." : "Resume"} {resumeRun.isPending ? "Resuming" : "Resume"}
</Button> </Button>
)} )}
</div> </div>
@@ -1898,6 +1978,20 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
</span> </span>
</div> </div>
)} )}
{Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
<ul className="list-disc pl-5 space-y-1">
{adapterInvokePayload.commandNotes
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.map((note, idx) => (
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
{note}
</li>
))}
</ul>
</div>
)}
{adapterInvokePayload.prompt !== undefined && ( {adapterInvokePayload.prompt !== undefined && (
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Prompt</div> <div className="text-xs text-muted-foreground mb-1">Prompt</div>
@@ -2147,7 +2241,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
/* ---- Keys Tab ---- */ /* ---- Keys Tab ---- */
function KeysTab({ agentId }: { agentId: string }) { function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [newKeyName, setNewKeyName] = useState(""); const [newKeyName, setNewKeyName] = useState("");
const [newToken, setNewToken] = useState<string | null>(null); const [newToken, setNewToken] = useState<string | null>(null);
@@ -2156,11 +2250,11 @@ function KeysTab({ agentId }: { agentId: string }) {
const { data: keys, isLoading } = useQuery({ const { data: keys, isLoading } = useQuery({
queryKey: queryKeys.agents.keys(agentId), queryKey: queryKeys.agents.keys(agentId),
queryFn: () => agentsApi.listKeys(agentId), queryFn: () => agentsApi.listKeys(agentId, companyId),
}); });
const createKey = useMutation({ const createKey = useMutation({
mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default"), mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default", companyId),
onSuccess: (data) => { onSuccess: (data) => {
setNewToken(data.token); setNewToken(data.token);
setTokenVisible(true); setTokenVisible(true);
@@ -2170,7 +2264,7 @@ function KeysTab({ agentId }: { agentId: string }) {
}); });
const revokeKey = useMutation({ const revokeKey = useMutation({
mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId), mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId, companyId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
}, },

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from "react"; 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 { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
@@ -12,7 +12,8 @@ import { StatusBadge } from "../components/StatusBadge";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors"; import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState"; 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 { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -121,6 +122,10 @@ export function Agents() {
return <EmptyState icon={Bot} message="Select a company to view agents." />; return <EmptyState icon={Bot} message="Select a company to view agents." />;
} }
if (isLoading) {
return <PageSkeleton variant="list" />;
}
const filtered = filterAgents(agents ?? [], tab, showTerminated); const filtered = filterAgents(agents ?? [], tab, showTerminated);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated); const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
@@ -204,7 +209,6 @@ export function Agents() {
<p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p> <p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p>
)} )}
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{agents && agents.length === 0 && ( {agents && agents.length === 0 && (
@@ -225,7 +229,7 @@ export function Agents() {
key={agent.id} key={agent.id}
title={agent.name} title={agent.name}
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`} subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={`/agents/${agent.id}`} to={agentUrl(agent)}
leading={ leading={
<span className="relative flex h-2.5 w-2.5"> <span className="relative flex h-2.5 w-2.5">
<span <span
@@ -238,7 +242,7 @@ export function Agents() {
<span className="sm:hidden"> <span className="sm:hidden">
{liveRunByAgent.has(agent.id) ? ( {liveRunByAgent.has(agent.id) ? (
<LiveRunIndicator <LiveRunIndicator
agentId={agent.id} agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId} runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount} liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/> />
@@ -249,7 +253,7 @@ export function Agents() {
<div className="hidden sm:flex items-center gap-3"> <div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(agent.id) && ( {liveRunByAgent.has(agent.id) && (
<LiveRunIndicator <LiveRunIndicator
agentId={agent.id} agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId} runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount} liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/> />
@@ -320,7 +324,7 @@ function OrgTreeNode({
return ( return (
<div style={{ paddingLeft: depth * 24 }}> <div style={{ paddingLeft: depth * 24 }}>
<Link <Link
to={`/agents/${node.id}`} to={agent ? agentUrl(agent) : `/agents/${node.id}`}
className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left no-underline text-inherit" className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left no-underline text-inherit"
> >
<span className="relative flex h-2.5 w-2.5 shrink-0"> <span className="relative flex h-2.5 w-2.5 shrink-0">
@@ -337,7 +341,7 @@ function OrgTreeNode({
<span className="sm:hidden"> <span className="sm:hidden">
{liveRunByAgent.has(node.id) ? ( {liveRunByAgent.has(node.id) ? (
<LiveRunIndicator <LiveRunIndicator
agentId={node.id} agentRef={agent ? agentRouteRef(agent) : node.id}
runId={liveRunByAgent.get(node.id)!.runId} runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount} liveCount={liveRunByAgent.get(node.id)!.liveCount}
/> />
@@ -348,7 +352,7 @@ function OrgTreeNode({
<div className="hidden sm:flex items-center gap-3"> <div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(node.id) && ( {liveRunByAgent.has(node.id) && (
<LiveRunIndicator <LiveRunIndicator
agentId={node.id} agentRef={agent ? agentRouteRef(agent) : node.id}
runId={liveRunByAgent.get(node.id)!.runId} runId={liveRunByAgent.get(node.id)!.runId}
liveCount={liveRunByAgent.get(node.id)!.liveCount} liveCount={liveRunByAgent.get(node.id)!.liveCount}
/> />
@@ -381,17 +385,17 @@ function OrgTreeNode({
} }
function LiveRunIndicator({ function LiveRunIndicator({
agentId, agentRef,
runId, runId,
liveCount, liveCount,
}: { }: {
agentId: string; agentRef: string;
runId: string; runId: string;
liveCount: number; liveCount: number;
}) { }) {
return ( return (
<Link <Link
to={`/agents/${agentId}/runs/${runId}`} to={`/agents/${agentRef}/runs/${runId}`}
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline" className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@@ -9,6 +9,7 @@ import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload"; import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react"; import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react";
@@ -17,7 +18,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
export function ApprovalDetail() { export function ApprovalDetail() {
const { approvalId } = useParams<{ approvalId: string }>(); const { approvalId } = useParams<{ approvalId: string }>();
const { selectedCompanyId } = useCompany(); const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -31,6 +32,7 @@ export function ApprovalDetail() {
queryFn: () => approvalsApi.get(approvalId!), queryFn: () => approvalsApi.get(approvalId!),
enabled: !!approvalId, enabled: !!approvalId,
}); });
const resolvedCompanyId = approval?.companyId ?? selectedCompanyId;
const { data: comments } = useQuery({ const { data: comments } = useQuery({
queryKey: queryKeys.approvals.comments(approvalId!), queryKey: queryKeys.approvals.comments(approvalId!),
@@ -45,11 +47,16 @@ export function ApprovalDetail() {
}); });
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(approval?.companyId ?? selectedCompanyId ?? ""), queryKey: queryKeys.agents.list(resolvedCompanyId ?? ""),
queryFn: () => agentsApi.list(approval?.companyId ?? selectedCompanyId ?? ""), queryFn: () => agentsApi.list(resolvedCompanyId ?? ""),
enabled: !!(approval?.companyId ?? selectedCompanyId), enabled: !!resolvedCompanyId,
}); });
useEffect(() => {
if (!approval?.companyId || approval.companyId === selectedCompanyId) return;
setSelectedCompanyId(approval.companyId, { source: "route_sync" });
}, [approval?.companyId, selectedCompanyId, setSelectedCompanyId]);
const agentNameById = useMemo(() => { const agentNameById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const agent of agents ?? []) map.set(agent.id, agent.name); 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"), onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"),
}); });
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>; if (isLoading) return <PageSkeleton variant="detail" />;
if (!approval) return <p className="text-sm text-muted-foreground">Approval not found.</p>; if (!approval) return <p className="text-sm text-muted-foreground">Approval not found.</p>;
const payload = approval.payload as Record<string, unknown>; const payload = approval.payload as Record<string, unknown>;
@@ -346,7 +353,7 @@ export function ApprovalDetail() {
onClick={() => addCommentMutation.mutate()} onClick={() => addCommentMutation.mutate()}
disabled={!commentBody.trim() || addCommentMutation.isPending} disabled={!commentBody.trim() || addCommentMutation.isPending}
> >
{addCommentMutation.isPending ? "Posting..." : "Post comment"} {addCommentMutation.isPending ? "Posting" : "Post comment"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@@ -11,6 +11,7 @@ import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
import { ShieldCheck } from "lucide-react"; import { ShieldCheck } from "lucide-react";
import { ApprovalCard } from "../components/ApprovalCard"; import { ApprovalCard } from "../components/ApprovalCard";
import { PageSkeleton } from "../components/PageSkeleton";
type StatusFilter = "pending" | "all"; type StatusFilter = "pending" | "all";
@@ -77,6 +78,10 @@ export function Approvals() {
return <p className="text-sm text-muted-foreground">Select a company first.</p>; return <p className="text-sm text-muted-foreground">Select a company first.</p>;
} }
if (isLoading) {
return <PageSkeleton variant="approvals" />;
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -95,11 +100,10 @@ export function Approvals() {
</Tabs> </Tabs>
</div> </div>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>} {actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!isLoading && filtered.length === 0 && ( {filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<ShieldCheck className="h-8 w-8 text-muted-foreground/30 mb-3" /> <ShieldCheck className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AsciiArtAnimation } from "@/components/AsciiArtAnimation";
import { Sparkles } from "lucide-react";
type AuthMode = "sign_in" | "sign_up"; type AuthMode = "sign_in" | "sign_up";
@@ -59,83 +61,102 @@ export function AuthPage() {
(mode === "sign_in" || name.trim().length > 0); (mode === "sign_in" || name.trim().length > 0);
if (isSessionLoading) { if (isSessionLoading) {
return <div className="mx-auto max-w-md py-16 text-sm text-muted-foreground">Loading...</div>; return (
<div className="fixed inset-0 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading</p>
</div>
);
} }
return ( return (
<div className="mx-auto max-w-md py-10"> <div className="fixed inset-0 flex bg-background">
<div className="rounded-lg border border-border bg-card p-6 shadow-sm"> {/* Left half — form */}
<h1 className="text-xl font-semibold"> <div className="w-full md:w-1/2 flex flex-col overflow-y-auto">
{mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"} <div className="w-full max-w-md mx-auto my-auto px-8 py-12">
</h1> <div className="flex items-center gap-2 mb-8">
<p className="mt-1 text-sm text-muted-foreground"> <Sparkles className="h-4 w-4 text-muted-foreground" />
{mode === "sign_in" <span className="text-sm font-medium">Paperclip</span>
? "Use your email and password to access this instance." </div>
: "Create an account for this instance. Email confirmation is not required in v1."}
</p>
<form <h1 className="text-xl font-semibold">
className="mt-5 space-y-3" {mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
onSubmit={(event) => { </h1>
event.preventDefault(); <p className="mt-1 text-sm text-muted-foreground">
mutation.mutate(); {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_up" && ( </p>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Name</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
/>
</label>
)}
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Email</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Password</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={mode === "sign_in" ? "current-password" : "new-password"}
/>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={!canSubmit || mutation.isPending} className="w-full">
{mutation.isPending
? "Working..."
: mode === "sign_in"
? "Sign In"
: "Create Account"}
</Button>
</form>
<div className="mt-4 text-sm text-muted-foreground"> <form
{mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "} className="mt-6 space-y-4"
<button onSubmit={(event) => {
type="button" event.preventDefault();
className="font-medium text-foreground underline underline-offset-2" mutation.mutate();
onClick={() => {
setError(null);
setMode(mode === "sign_in" ? "sign_up" : "sign_in");
}} }}
> >
{mode === "sign_in" ? "Create one" : "Sign in"} {mode === "sign_up" && (
</button> <div>
<label className="text-xs text-muted-foreground mb-1 block">Name</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
autoFocus
/>
</div>
)}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Email</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
autoFocus={mode === "sign_in"}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Password</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={mode === "sign_in" ? "current-password" : "new-password"}
/>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
<Button type="submit" disabled={!canSubmit || mutation.isPending} className="w-full">
{mutation.isPending
? "Working…"
: mode === "sign_in"
? "Sign In"
: "Create Account"}
</Button>
</form>
<div className="mt-5 text-sm text-muted-foreground">
{mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
<button
type="button"
className="font-medium text-foreground underline underline-offset-2"
onClick={() => {
setError(null);
setMode(mode === "sign_in" ? "sign_up" : "sign_in");
}}
>
{mode === "sign_in" ? "Create one" : "Sign in"}
</button>
</div>
</div> </div>
</div> </div>
{/* Right half — ASCII art animation (hidden on mobile) */}
<div className="hidden md:block w-1/2 overflow-hidden">
<AsciiArtAnimation />
</div>
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { accessApi } from "../api/access";
import { authApi } from "../api/auth"; import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
@@ -117,7 +117,7 @@ export function BoardClaimPage() {
onClick={() => claimMutation.mutate()} onClick={() => claimMutation.mutate()}
disabled={claimMutation.isPending} disabled={claimMutation.isPending}
> >
{claimMutation.isPending ? "Claiming..." : "Claim ownership"} {claimMutation.isPending ? "Claiming" : "Claim ownership"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -283,7 +283,7 @@ export function Companies() {
onClick={() => deleteMutation.mutate(company.id)} onClick={() => deleteMutation.mutate(company.id)}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
{deleteMutation.isPending ? "Deleting..." : "Delete"} {deleteMutation.isPending ? "Deleting" : "Delete"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatCents, formatTokens } from "../lib/utils"; import { formatCents, formatTokens } from "../lib/utils";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
@@ -89,6 +90,10 @@ export function Costs() {
return <EmptyState icon={DollarSign} message="Select a company to view costs." />; return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
} }
if (isLoading) {
return <PageSkeleton variant="costs" />;
}
const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"]; const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
return ( return (
@@ -124,7 +129,6 @@ export function Costs() {
)} )}
</div> </div>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{data && ( {data && (
@@ -151,7 +155,7 @@ export function Costs() {
{data.summary.budgetCents > 0 && ( {data.summary.budgetCents > 0 && (
<div className="w-full h-2 bg-muted rounded-full overflow-hidden"> <div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${ className={`h-full rounded-full transition-[width,background-color] duration-150 ${
data.summary.utilizationPercent > 90 data.summary.utilizationPercent > 90
? "bg-red-400" ? "bg-red-400"
: data.summary.utilizationPercent > 70 : data.summary.utilizationPercent > 70

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; 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 { useQuery } from "@tanstack/react-query";
import { dashboardApi } from "../api/dashboard"; import { dashboardApi } from "../api/dashboard";
import { activityApi } from "../api/activity"; 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 { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel"; import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { PageSkeleton } from "../components/PageSkeleton";
import type { Agent, Issue } from "@paperclip/shared"; import type { Agent, Issue } from "@paperclip/shared";
function getRecentIssues(issues: Issue[]): Issue[] { function getRecentIssues(issues: Issue[]): Issue[] {
@@ -177,9 +178,12 @@ export function Dashboard() {
); );
} }
if (isLoading) {
return <PageSkeleton variant="dashboard" />;
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
<ActiveAgentsPanel companyId={selectedCompanyId!} /> <ActiveAgentsPanel companyId={selectedCompanyId!} />
@@ -256,11 +260,11 @@ export function Dashboard() {
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{/* Recent Activity */} {/* Recent Activity */}
{recentActivity.length > 0 && ( {recentActivity.length > 0 && (
<div> <div className="min-w-0">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3"> <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Activity Recent Activity
</h3> </h3>
<div className="border border-border divide-y divide-border"> <div className="border border-border divide-y divide-border overflow-hidden">
{recentActivity.map((event) => ( {recentActivity.map((event) => (
<ActivityRow <ActivityRow
key={event.id} key={event.id}
@@ -276,7 +280,7 @@ export function Dashboard() {
)} )}
{/* Recent Tasks */} {/* Recent Tasks */}
<div> <div className="min-w-0">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3"> <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Tasks Recent Tasks
</h3> </h3>
@@ -285,7 +289,7 @@ export function Dashboard() {
<p className="text-sm text-muted-foreground">No tasks yet.</p> <p className="text-sm text-muted-foreground">No tasks yet.</p>
</div> </div>
) : ( ) : (
<div className="border border-border divide-y divide-border"> <div className="border border-border divide-y divide-border overflow-hidden">
{recentIssues.slice(0, 10).map((issue) => ( {recentIssues.slice(0, 10).map((issue) => (
<Link <Link
key={issue.id} key={issue.id}
@@ -298,7 +302,7 @@ export function Dashboard() {
<PriorityIcon priority={issue.priority} /> <PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} /> <StatusIcon status={issue.status} />
</div> </div>
<p className="min-w-0 flex-1 sm:truncate"> <p className="min-w-0 flex-1 truncate">
<span>{issue.title}</span> <span>{issue.title}</span>
{issue.assigneeAgentId && (() => { {issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId); const name = agentName(issue.assigneeAgentId);

View File

@@ -1038,7 +1038,7 @@ export function DesignGuide() {
</div> </div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden"> <div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${color}`} className={`h-full rounded-full transition-[width,background-color] duration-150 ${color}`}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { goalsApi } from "../api/goals"; import { goalsApi } from "../api/goals";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
@@ -14,6 +14,8 @@ import { GoalTree } from "../components/GoalTree";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { PageSkeleton } from "../components/PageSkeleton";
import { projectUrl } from "../lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
@@ -21,7 +23,7 @@ import type { Goal, Project } from "@paperclip/shared";
export function GoalDetail() { export function GoalDetail() {
const { goalId } = useParams<{ goalId: string }>(); const { goalId } = useParams<{ goalId: string }>();
const { selectedCompanyId } = useCompany(); const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openNewGoal } = useDialog(); const { openNewGoal } = useDialog();
const { openPanel, closePanel } = usePanel(); const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
@@ -36,19 +38,25 @@ export function GoalDetail() {
queryFn: () => goalsApi.get(goalId!), queryFn: () => goalsApi.get(goalId!),
enabled: !!goalId enabled: !!goalId
}); });
const resolvedCompanyId = goal?.companyId ?? selectedCompanyId;
const { data: allGoals } = useQuery({ const { data: allGoals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!), queryKey: queryKeys.goals.list(resolvedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!), queryFn: () => goalsApi.list(resolvedCompanyId!),
enabled: !!selectedCompanyId enabled: !!resolvedCompanyId
}); });
const { data: allProjects } = useQuery({ const { data: allProjects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!), queryKey: queryKeys.projects.list(resolvedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(resolvedCompanyId!),
enabled: !!selectedCompanyId enabled: !!resolvedCompanyId
}); });
useEffect(() => {
if (!goal?.companyId || goal.companyId === selectedCompanyId) return;
setSelectedCompanyId(goal.companyId, { source: "route_sync" });
}, [goal?.companyId, selectedCompanyId, setSelectedCompanyId]);
const updateGoal = useMutation({ const updateGoal = useMutation({
mutationFn: (data: Record<string, unknown>) => mutationFn: (data: Record<string, unknown>) =>
goalsApi.update(goalId!, data), goalsApi.update(goalId!, data),
@@ -56,9 +64,9 @@ export function GoalDetail() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.goals.detail(goalId!) queryKey: queryKeys.goals.detail(goalId!)
}); });
if (selectedCompanyId) { if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(selectedCompanyId) queryKey: queryKeys.goals.list(resolvedCompanyId)
}); });
} }
} }
@@ -66,9 +74,9 @@ export function GoalDetail() {
const uploadImage = useMutation({ const uploadImage = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected"); if (!resolvedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage( return assetsApi.uploadImage(
selectedCompanyId, resolvedCompanyId,
file, file,
`goals/${goalId ?? "draft"}` `goals/${goalId ?? "draft"}`
); );
@@ -102,8 +110,7 @@ export function GoalDetail() {
return () => closePanel(); return () => closePanel();
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps }, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
if (isLoading) if (isLoading) return <PageSkeleton variant="detail" />;
return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>; if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!goal) return null; if (!goal) return null;
@@ -176,7 +183,7 @@ export function GoalDetail() {
key={project.id} key={project.id}
title={project.name} title={project.name}
subtitle={project.description ?? undefined} subtitle={project.description ?? undefined}
to={`/projects/${project.id}`} to={projectUrl(project)}
trailing={<StatusBadge status={project.status} />} trailing={<StatusBadge status={project.status} />}
/> />
))} ))}

View File

@@ -7,6 +7,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { GoalTree } from "../components/GoalTree"; import { GoalTree } from "../components/GoalTree";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Target, Plus } from "lucide-react"; import { Target, Plus } from "lucide-react";
@@ -29,9 +30,12 @@ export function Goals() {
return <EmptyState icon={Target} message="Select a company to view goals." />; return <EmptyState icon={Target} message="Select a company to view goals." />;
} }
if (isLoading) {
return <PageSkeleton variant="list" />;
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{goals && goals.length === 0 && ( {goals && goals.length === 0 && (

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
import { accessApi } from "../api/access"; import { accessApi } from "../api/access";
@@ -14,6 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
import { StatusIcon } from "../components/StatusIcon"; import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon"; import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { ApprovalCard } from "../components/ApprovalCard"; import { ApprovalCard } from "../components/ApprovalCard";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
@@ -208,7 +209,7 @@ function FailedRunCard({
disabled={retryRun.isPending} disabled={retryRun.isPending}
> >
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{retryRun.isPending ? "Retrying..." : "Retry"} {retryRun.isPending ? "Retrying" : "Retry"}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -562,7 +563,7 @@ export function Inbox() {
{actionError && <p className="text-sm text-destructive">{actionError}</p>} {actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!allLoaded && visibleSections.length === 0 && ( {!allLoaded && visibleSections.length === 0 && (
<p className="text-sm text-muted-foreground">Loading...</p> <PageSkeleton variant="inbox" />
)} )}
{allLoaded && visibleSections.length === 0 && ( {allLoaded && visibleSections.length === 0 && (

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "@/lib/router";
import { accessApi } from "../api/access"; import { accessApi } from "../api/access";
import { authApi } from "../api/auth"; import { authApi } from "../api/auth";
import { healthApi } from "../api/health"; import { healthApi } from "../api/health";
@@ -154,12 +154,19 @@ export function InviteLandingPage() {
claimSecret?: string; claimSecret?: string;
claimApiKeyPath?: string; claimApiKeyPath?: string;
onboarding?: Record<string, unknown>; onboarding?: Record<string, unknown>;
diagnostics?: Array<{
code: string;
level: "info" | "warn";
message: string;
hint?: string;
}>;
}; };
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null; const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null; const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]); const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]); const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]); const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
return ( return (
<div className="mx-auto max-w-xl py-10"> <div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6"> <div className="rounded-lg border border-border bg-card p-6">
@@ -185,6 +192,19 @@ export function InviteLandingPage() {
{onboardingInstallPath && <p className="font-mono break-all">Install to {onboardingInstallPath}</p>} {onboardingInstallPath && <p className="font-mono break-all">Install to {onboardingInstallPath}</p>}
</div> </div>
)} )}
{diagnostics.length > 0 && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Connectivity diagnostics</p>
{diagnostics.map((diag, idx) => (
<div key={`${diag.code}:${idx}`} className="space-y-0.5">
<p className={diag.level === "warn" ? "text-amber-600 dark:text-amber-400" : undefined}>
[{diag.level}] {diag.message}
</p>
{diag.hint && <p className="font-mono break-all">{diag.hint}</p>}
</div>
))}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -276,7 +296,7 @@ export function InviteLandingPage() {
onClick={() => acceptMutation.mutate()} onClick={() => acceptMutation.mutate()}
> >
{acceptMutation.isPending {acceptMutation.isPending
? "Submitting..." ? "Submitting"
: invite.inviteType === "bootstrap_ceo" : invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite" ? "Accept bootstrap invite"
: "Submit join request"} : "Submit join request"}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react"; 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";

View File

@@ -8,6 +8,7 @@ import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon"; import { PriorityIcon } from "../components/PriorityIcon";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate } from "../lib/utils"; import { formatDate } from "../lib/utils";
import { ListTodo } from "lucide-react"; import { ListTodo } from "lucide-react";
@@ -29,6 +30,10 @@ export function MyIssues() {
return <EmptyState icon={ListTodo} message="Select a company to view your issues." />; return <EmptyState icon={ListTodo} message="Select a company to view your issues." />;
} }
if (isLoading) {
return <PageSkeleton variant="list" />;
}
// Show issues that are not assigned (user-created or unassigned) // Show issues that are not assigned (user-created or unassigned)
const myIssues = (issues ?? []).filter( const myIssues = (issues ?? []).filter(
(i) => !i.assigneeAgentId && !["done", "cancelled"].includes(i.status) (i) => !i.assigneeAgentId && !["done", "cancelled"].includes(i.status)
@@ -36,10 +41,9 @@ export function MyIssues() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{!isLoading && myIssues.length === 0 && ( {myIssues.length === 0 && (
<EmptyState icon={ListTodo} message="No issues assigned to you." /> <EmptyState icon={ListTodo} message="No issues assigned to you." />
)} )}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@@ -7,6 +7,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { ChevronRight, GitBranch } from "lucide-react"; import { ChevronRight, GitBranch } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -106,9 +107,12 @@ export function Org() {
return <EmptyState icon={GitBranch} message="Select a company to view org chart." />; return <EmptyState icon={GitBranch} message="Select a company to view org chart." />;
} }
if (isLoading) {
return <PageSkeleton variant="list" />;
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{data && data.length === 0 && ( {data && data.length === 0 && (

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react"; 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 { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { agentUrl } from "../lib/utils";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker"; import { AgentIcon } from "../components/AgentIconPicker";
import { Network } from "lucide-react"; import { Network } from "lucide-react";
import type { Agent } from "@paperclip/shared"; import type { Agent } from "@paperclip/shared";
@@ -254,7 +256,7 @@ export function OrgChart() {
} }
if (isLoading) { if (isLoading) {
return <p className="text-sm text-muted-foreground p-4">Loading...</p>; return <PageSkeleton variant="org-chart" />;
} }
if (orgTree && orgTree.length === 0) { if (orgTree && orgTree.length === 0) {
@@ -287,6 +289,7 @@ export function OrgChart() {
} }
setZoom(newZoom); setZoom(newZoom);
}} }}
aria-label="Zoom in"
> >
+ +
</button> </button>
@@ -303,6 +306,7 @@ export function OrgChart() {
} }
setZoom(newZoom); setZoom(newZoom);
}} }}
aria-label="Zoom out"
> >
&minus; &minus;
</button> </button>
@@ -321,6 +325,7 @@ export function OrgChart() {
setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 }); setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 });
}} }}
title="Fit to screen" title="Fit to screen"
aria-label="Fit chart to screen"
> >
Fit Fit
</button> </button>
@@ -371,14 +376,14 @@ export function OrgChart() {
<div <div
key={node.id} key={node.id}
data-org-card data-org-card
className="absolute bg-card border border-border rounded-lg shadow-sm hover:shadow-md hover:border-foreground/20 transition-all cursor-pointer select-none" className="absolute bg-card border border-border rounded-lg shadow-sm hover:shadow-md hover:border-foreground/20 transition-[box-shadow,border-color] duration-150 cursor-pointer select-none"
style={{ style={{
left: node.x, left: node.x,
top: node.y, top: node.y,
width: CARD_W, width: CARD_W,
minHeight: CARD_H, minHeight: CARD_H,
}} }}
onClick={() => navigate(`/agents/${node.id}`)} onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)}
> >
<div className="flex items-center px-4 py-3 gap-3"> <div className="flex items-center px-4 py-3 gap-3">
{/* Agent icon + status dot */} {/* Agent icon + status dot */}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, useRef } from "react"; 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 { 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 { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@@ -15,15 +15,20 @@ import { ProjectProperties } from "../components/ProjectProperties";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { IssuesList } from "../components/IssuesList"; import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { projectRouteRef } from "../lib/utils";
/* ── Top-level tab types ── */ /* ── Top-level tab types ── */
type ProjectTab = "overview" | "list"; type ProjectTab = "overview" | "list";
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null { function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
const prefix = `/projects/${projectId}`; const segments = pathname.split("/").filter(Boolean);
if (pathname === `${prefix}/overview`) return "overview"; const projectsIdx = segments.indexOf("projects");
if (pathname.startsWith(`${prefix}/issues`)) return "list"; 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; return null;
} }
@@ -95,7 +100,7 @@ function ColorPicker({
<div className="relative" ref={ref}> <div className="relative" ref={ref}>
<button <button
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-all" className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
style={{ backgroundColor: currentColor }} style={{ backgroundColor: currentColor }}
aria-label="Change project color" aria-label="Change project color"
/> />
@@ -109,7 +114,7 @@ function ColorPicker({
onSelect(color); onSelect(color);
setOpen(false); setOpen(false);
}} }}
className={`h-6 w-6 rounded-md cursor-pointer transition-all hover:scale-110 ${ className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
color === currentColor color === currentColor
? "ring-2 ring-foreground ring-offset-1 ring-offset-background" ? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
: "hover:ring-2 hover:ring-foreground/30" : "hover:ring-2 hover:ring-foreground/30"
@@ -127,20 +132,19 @@ function ColorPicker({
/* ── List (issues) tab content ── */ /* ── List (issues) tab content ── */
function ProjectIssuesList({ projectId }: { projectId: string }) { function ProjectIssuesList({ projectId, companyId }: { projectId: string; companyId: string }) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(selectedCompanyId!), queryFn: () => agentsApi.list(companyId),
enabled: !!selectedCompanyId, enabled: !!companyId,
}); });
const { data: liveRuns } = useQuery({ const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!), queryKey: queryKeys.liveRuns(companyId),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
enabled: !!selectedCompanyId, enabled: !!companyId,
refetchInterval: 5000, refetchInterval: 5000,
}); });
@@ -153,17 +157,17 @@ function ProjectIssuesList({ projectId }: { projectId: string }) {
}, [liveRuns]); }, [liveRuns]);
const { data: issues, isLoading, error } = useQuery({ const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId), queryKey: queryKeys.issues.listByProject(companyId, projectId),
queryFn: () => issuesApi.list(selectedCompanyId!, { projectId }), queryFn: () => issuesApi.list(companyId, { projectId }),
enabled: !!selectedCompanyId, enabled: !!companyId,
}); });
const updateIssue = useMutation({ const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data), issuesApi.update(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
}, },
}); });
@@ -184,47 +188,87 @@ function ProjectIssuesList({ projectId }: { projectId: string }) {
/* ── Main project page ── */ /* ── Main project page ── */
export function ProjectDetail() { export function ProjectDetail() {
const { projectId } = useParams<{ projectId: string }>(); const { companyPrefix, projectId, filter } = useParams<{
const { selectedCompanyId } = useCompany(); companyPrefix?: string;
projectId: string;
filter?: string;
}>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openPanel, closePanel } = usePanel(); const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const routeProjectRef = projectId ?? "";
const routeCompanyId = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
}, [companies, companyPrefix]);
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
const activeTab = projectId ? resolveProjectTab(location.pathname, projectId) : null; const activeTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
const { data: project, isLoading, error } = useQuery({ const { data: project, isLoading, error } = useQuery({
queryKey: queryKeys.projects.detail(projectId!), queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
queryFn: () => projectsApi.get(projectId!), queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
enabled: !!projectId, enabled: canFetchProject,
}); });
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const projectLookupRef = project?.id ?? routeProjectRef;
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return;
setSelectedCompanyId(project.companyId, { source: "route_sync" });
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
const invalidateProject = () => { const invalidateProject = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) }); if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) });
} }
}; };
const updateProject = useMutation({ const updateProject = useMutation({
mutationFn: (data: Record<string, unknown>) => projectsApi.update(projectId!, data), mutationFn: (data: Record<string, unknown>) =>
projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId),
onSuccess: invalidateProject, onSuccess: invalidateProject,
}); });
const uploadImage = useMutation({ const uploadImage = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected"); if (!resolvedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(selectedCompanyId, file, `projects/${projectId ?? "draft"}`); return assetsApi.uploadImage(resolvedCompanyId, file, `projects/${projectLookupRef || "draft"}`);
}, },
}); });
useEffect(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
{ label: "Projects", href: "/projects" }, { label: "Projects", href: "/projects" },
{ label: project?.name ?? projectId ?? "Project" }, { label: project?.name ?? routeProjectRef ?? "Project" },
]); ]);
}, [setBreadcrumbs, project, projectId]); }, [setBreadcrumbs, project, routeProjectRef]);
useEffect(() => {
if (!project) return;
if (routeProjectRef === canonicalProjectRef) return;
if (activeTab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
return;
}
if (activeTab === "list") {
if (filter) {
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
return;
}
navigate(`/projects/${canonicalProjectRef}/issues`, { replace: true });
return;
}
navigate(`/projects/${canonicalProjectRef}`, { replace: true });
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]);
useEffect(() => { useEffect(() => {
if (project) { if (project) {
@@ -234,19 +278,19 @@ export function ProjectDetail() {
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps }, [project]); // eslint-disable-line react-hooks/exhaustive-deps
// Redirect bare /projects/:id to /projects/:id/issues // Redirect bare /projects/:id to /projects/:id/issues
if (projectId && activeTab === null) { if (routeProjectRef && activeTab === null) {
return <Navigate to={`/projects/${projectId}/issues`} replace />; return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
} }
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>; if (isLoading) return <PageSkeleton variant="detail" />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>; if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!project) return null; if (!project) return null;
const handleTabChange = (tab: ProjectTab) => { const handleTabChange = (tab: ProjectTab) => {
if (tab === "overview") { if (tab === "overview") {
navigate(`/projects/${projectId}/overview`); navigate(`/projects/${canonicalProjectRef}/overview`);
} else { } else {
navigate(`/projects/${projectId}/issues`); navigate(`/projects/${canonicalProjectRef}/issues`);
} }
}; };
@@ -303,8 +347,8 @@ export function ProjectDetail() {
/> />
)} )}
{activeTab === "list" && projectId && ( {activeTab === "list" && project?.id && resolvedCompanyId && (
<ProjectIssuesList projectId={projectId} /> <ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
)} )}
</div> </div>
); );

View File

@@ -8,7 +8,8 @@ import { queryKeys } from "../lib/queryKeys";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState"; 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 { Button } from "@/components/ui/button";
import { Hexagon, Plus } from "lucide-react"; import { Hexagon, Plus } from "lucide-react";
@@ -31,6 +32,10 @@ export function Projects() {
return <EmptyState icon={Hexagon} message="Select a company to view projects." />; return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
} }
if (isLoading) {
return <PageSkeleton variant="list" />;
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
@@ -40,7 +45,6 @@ export function Projects() {
</Button> </Button>
</div> </div>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{projects && projects.length === 0 && ( {projects && projects.length === 0 && (
@@ -59,7 +63,7 @@ export function Projects() {
key={project.id} key={project.id}
title={project.name} title={project.name}
subtitle={project.description ?? undefined} subtitle={project.description ?? undefined}
to={`/projects/${project.id}`} to={projectUrl(project)}
trailing={ trailing={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{project.targetDate && ( {project.targetDate && (