+
{filteredOptions.length === 0 ? (
{emptyMessage}
) : (
@@ -169,7 +173,7 @@ export const InlineEntitySelector = forwardRef
setHighlightedIndex(index)}
diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx
index 75e13bcf..23ddbb14 100644
--- a/ui/src/components/IssueProperties.tsx
+++ b/ui/src/components/IssueProperties.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { Link } from "react-router-dom";
+import { Link } from "@/lib/router";
import type { Issue } from "@paperclip/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
@@ -12,7 +12,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
-import { formatDate, cn } from "../lib/utils";
+import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -175,6 +175,11 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const project = orderedProjects.find((p) => p.id === id);
return project?.name ?? id.slice(0, 8);
};
+ const projectLink = (id: string | null) => {
+ if (!id) return null;
+ const project = projects?.find((p) => p.id === id) ?? null;
+ return project ? projectUrl(project) : `/projects/${id}`;
+ };
const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId)
@@ -283,7 +288,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
}
>
- {createLabel.isPending ? "Creating..." : "Create label"}
+ {createLabel.isPending ? "Creating…" : "Create label"}
>
@@ -482,7 +487,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
popoverClassName="w-fit min-w-[11rem]"
extra={issue.projectId ? (
e.stopPropagation()}
>
diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx
index 5b47ed83..646cf6c5 100644
--- a/ui/src/components/KanbanBoard.tsx
+++ b/ui/src/components/KanbanBoard.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
-import { Link } from "react-router-dom";
+import { Link } from "@/lib/router";
import {
DndContext,
DragOverlay,
diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx
index ff1f28bf..58546c99 100644
--- a/ui/src/components/Layout.tsx
+++ b/ui/src/components/Layout.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState, type UIEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Sun } from "lucide-react";
-import { Outlet } from "react-router-dom";
+import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { SidebarNavItem } from "./SidebarNavItem";
@@ -31,8 +31,11 @@ export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { panelContent, closePanel } = usePanel();
- const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany();
+ const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { theme, toggleTheme } = useTheme();
+ const { companyPrefix } = useParams<{ companyPrefix: string }>();
+ const navigate = useNavigate();
+ const location = useLocation();
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
@@ -52,6 +55,40 @@ export function Layout() {
}
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
+ useEffect(() => {
+ if (!companyPrefix || companiesLoading || companies.length === 0) return;
+
+ const requestedPrefix = companyPrefix.toUpperCase();
+ const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix);
+
+ if (!matched) {
+ const fallback =
+ (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
+ ?? companies[0]!;
+ navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true });
+ return;
+ }
+
+ if (companyPrefix !== matched.issuePrefix) {
+ const suffix = location.pathname.replace(/^\/[^/]+/, "");
+ navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true });
+ return;
+ }
+
+ if (selectedCompanyId !== matched.id) {
+ setSelectedCompanyId(matched.id, { source: "route_sync" });
+ }
+ }, [
+ companyPrefix,
+ companies,
+ companiesLoading,
+ location.pathname,
+ location.search,
+ navigate,
+ selectedCompanyId,
+ setSelectedCompanyId,
+ ]);
+
const togglePanel = useCallback(() => {
if (panelContent) closePanel();
}, [panelContent, closePanel]);
@@ -151,11 +188,19 @@ export function Layout() {
return (
+
+ Skip to Main Content
+
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
-
setSidebarOpen(false)}
+ aria-label="Close sidebar"
/>
)}
@@ -163,7 +208,7 @@ export function Layout() {
{isMobile ? (
@@ -199,7 +244,7 @@ export function Layout() {
@@ -235,6 +280,8 @@ export function Layout() {
diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx
index 11e1e5d5..8a12040f 100644
--- a/ui/src/components/LiveRunWidget.tsx
+++ b/ui/src/components/LiveRunWidget.tsx
@@ -1,14 +1,15 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
-import { Link } from "react-router-dom";
+import { Link } from "@/lib/router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclip/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { getUIAdapter } from "../adapters";
import type { TranscriptEntry } from "../adapters";
import { queryKeys } from "../lib/queryKeys";
-import { cn, relativeTime } from "../lib/utils";
+import { cn, relativeTime, formatDateTime } from "../lib/utils";
import { ExternalLink, Square } from "lucide-react";
import { Identity } from "./Identity";
+import { StatusBadge } from "./StatusBadge";
interface LiveRunWidgetProps {
issueId: string;
@@ -311,55 +312,54 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
if (runs.length === 0 && feed.length === 0) return null;
const recent = feed.slice(-25);
- const headerRun =
- runs[0] ??
- (() => {
- const last = recent[recent.length - 1];
- if (!last) return null;
- const meta = runMetaByIdRef.current.get(last.runId);
- if (!meta) return null;
- return {
- id: last.runId,
- agentId: meta.agentId,
- };
- })();
return (
-
-
- {runs.length > 0 && (
-
-
-
-
- )}
-
- {runs.length > 0 ? `Live issue runs (${runs.length})` : "Recent run updates"}
-
-
- {headerRun && (
-
- {runs.length > 0 && (
-
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"
+ {runs.length > 0 ? (
+ runs.map((run) => (
+
+
+
+
+
+
+ {formatDateTime(run.startedAt ?? run.createdAt)}
+
+
+
+
Run
+
-
- {cancellingRunIds.has(headerRun.id) ? "Stopping…" : "Stop"}
-
- )}
-
- Open run
-
-
+ {run.id.slice(0, 8)}
+
+
+
+ handleCancelRun(run.id)}
+ 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"
+ >
+
+ {cancellingRunIds.has(run.id) ? "Stopping…" : "Stop"}
+
+
+ Open run
+
+
+
+
- )}
-
+ ))
+ ) : (
+
+ Recent run updates
+
+ )}
{recent.length === 0 && (
@@ -390,21 +390,6 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
))}
- {runs.length > 1 && (
-
- {runs.map((run) => (
-
-
- {run.id.slice(0, 8)}
-
-
-
- ))}
-
- )}
);
}
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx
index e41391d6..5d40909d 100644
--- a/ui/src/components/MarkdownEditor.tsx
+++ b/ui/src/components/MarkdownEditor.tsx
@@ -10,9 +10,11 @@ import {
type DragEvent,
} from "react";
import {
+ CodeMirrorEditor,
MDXEditor,
codeBlockPlugin,
codeMirrorPlugin,
+ type CodeBlockEditorDescriptor,
type MDXEditorMethods,
headingsPlugin,
imagePlugin,
@@ -90,6 +92,14 @@ const CODE_BLOCK_LANGUAGES: Record
= {
yml: "YAML",
};
+const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
+ // Keep this lower than codeMirrorPlugin's descriptor priority so known languages
+ // still use the standard matching path; this catches malformed/unknown fences.
+ priority: 0,
+ match: () => true,
+ Editor: CodeMirrorEditor,
+};
+
function detectMention(container: HTMLElement): MentionState | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
@@ -247,7 +257,10 @@ export const MarkdownEditor = forwardRef
linkPlugin(),
linkDialogPlugin(),
thematicBreakPlugin(),
- codeBlockPlugin(),
+ codeBlockPlugin({
+ defaultCodeBlockLanguage: "txt",
+ codeBlockEditorDescriptors: [FALLBACK_CODE_BLOCK_DESCRIPTOR],
+ }),
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
markdownShortcutPlugin(),
];
diff --git a/ui/src/components/MetricCard.tsx b/ui/src/components/MetricCard.tsx
index 470ceab5..b954367d 100644
--- a/ui/src/components/MetricCard.tsx
+++ b/ui/src/components/MetricCard.tsx
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
-import { Link } from "react-router-dom";
+import { Link } from "@/lib/router";
interface MetricCardProps {
icon: LucideIcon;
diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx
index 61091d30..e9e5c150 100644
--- a/ui/src/components/MobileBottomNav.tsx
+++ b/ui/src/components/MobileBottomNav.tsx
@@ -1,5 +1,5 @@
import { useMemo } from "react";
-import { NavLink, useLocation } from "react-router-dom";
+import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import {
House,
@@ -75,7 +75,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
{items.map((item) => {
if (item.type === "action") {
const Icon = item.icon;
- const active = location.pathname.startsWith("/issues/new");
+ const active = /\/issues\/new(?:\/|$)/.test(location.pathname);
return (
- {createAgent.isPending ? "Creating..." : "Create agent"}
+ {createAgent.isPending ? "Creating…" : "Create agent"}
diff --git a/ui/src/components/NewGoalDialog.tsx b/ui/src/components/NewGoalDialog.tsx
index 2dc5453d..41e711fb 100644
--- a/ui/src/components/NewGoalDialog.tsx
+++ b/ui/src/components/NewGoalDialog.tsx
@@ -273,7 +273,7 @@ export function NewGoalDialog() {
disabled={!title.trim() || createGoal.isPending}
onClick={handleSubmit}
>
- {createGoal.isPending ? "Creating..." : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"}
+ {createGoal.isPending ? "Creating…" : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"}
diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx
index b163f3dc..4db14c26 100644
--- a/ui/src/components/NewProjectDialog.tsx
+++ b/ui/src/components/NewProjectDialog.tsx
@@ -468,7 +468,7 @@ export function NewProjectDialog() {
disabled={!name.trim() || createProject.isPending}
onClick={handleSubmit}
>
- {createProject.isPending ? "Creating..." : "Create project"}
+ {createProject.isPending ? "Creating…" : "Create project"}
diff --git a/ui/src/components/PageSkeleton.tsx b/ui/src/components/PageSkeleton.tsx
index 05366944..cf2e2abd 100644
--- a/ui/src/components/PageSkeleton.tsx
+++ b/ui/src/components/PageSkeleton.tsx
@@ -1,26 +1,160 @@
import { Skeleton } from "@/components/ui/skeleton";
interface PageSkeletonProps {
- variant?: "list" | "detail";
+ variant?:
+ | "list"
+ | "issues-list"
+ | "detail"
+ | "dashboard"
+ | "approvals"
+ | "costs"
+ | "inbox"
+ | "org-chart";
}
export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
+ if (variant === "dashboard") {
+ return (
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+ }
+
+ if (variant === "approvals") {
+ return (
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (variant === "costs") {
+ return (
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (variant === "inbox") {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, section) => (
+
+
+
+ {Array.from({ length: 3 }).map((_, row) => (
+
+ ))}
+
+
+ ))}
+
+
+ );
+ }
+
+ if (variant === "org-chart") {
+ return (
+
+
+
+ );
+ }
+
if (variant === "detail") {
return (
-
+ );
+ }
+
+ if (variant === "issues-list") {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
);
@@ -28,14 +162,17 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
return (
-
-
-
+
-
+
- {Array.from({ length: 8 }).map((_, i) => (
-
+ {Array.from({ length: 7 }).map((_, i) => (
+
))}
diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx
index 1d126016..d4e8ca0e 100644
--- a/ui/src/components/ProjectProperties.tsx
+++ b/ui/src/components/ProjectProperties.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { Link } from "react-router-dom";
+import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclip/shared";
import { StatusBadge } from "./StatusBadge";
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx
index d6e89b42..c1b57f7e 100644
--- a/ui/src/components/Sidebar.tsx
+++ b/ui/src/components/Sidebar.tsx
@@ -21,7 +21,6 @@ import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
-import { ScrollArea } from "@/components/ui/scroll-area";
export function Sidebar() {
const { openNewIssue } = useDialog();
@@ -66,45 +65,43 @@ export function Sidebar() {
-
-
-
- {/* New Issue button aligned with nav items */}
- 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"
- >
-
- New Issue
-
-
- 0}
- />
-
+
+
+ {/* New Issue button aligned with nav items */}
+ 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"
+ >
+
+ New Issue
+
+
+ 0}
+ />
+
-
-
-
-
+
+
+
+
-
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx
index 8687add1..7b469771 100644
--- a/ui/src/components/SidebarAgents.tsx
+++ b/ui/src/components/SidebarAgents.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
-import { NavLink, useLocation } from "react-router-dom";
+import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { useCompany } from "../context/CompanyContext";
@@ -7,7 +7,7 @@ import { useSidebar } from "../context/SidebarContext";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
-import { cn } from "../lib/utils";
+import { cn, agentRouteRef, agentUrl } from "../lib/utils";
import { AgentIcon } from "./AgentIconPicker";
import {
Collapsible,
@@ -71,7 +71,7 @@ export function SidebarAgents() {
return sortByHierarchy(filtered);
}, [agents]);
- const agentMatch = location.pathname.match(/^\/agents\/([^/]+)/);
+ const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)/);
const activeAgentId = agentMatch?.[1] ?? null;
return (
@@ -99,13 +99,13 @@ export function SidebarAgents() {
return (
{
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
- activeAgentId === agent.id
+ activeAgentId === agentRouteRef(agent)
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
)}
diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx
index cd42c86c..6d3f4995 100644
--- a/ui/src/components/SidebarNavItem.tsx
+++ b/ui/src/components/SidebarNavItem.tsx
@@ -1,4 +1,4 @@
-import { NavLink } from "react-router-dom";
+import { NavLink } from "@/lib/router";
import { cn } from "../lib/utils";
import { useSidebar } from "../context/SidebarContext";
import type { LucideIcon } from "lucide-react";
diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx
index 0ff66459..f32e6b40 100644
--- a/ui/src/components/SidebarProjects.tsx
+++ b/ui/src/components/SidebarProjects.tsx
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
-import { NavLink, useLocation } from "react-router-dom";
+import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react";
import {
@@ -18,7 +18,7 @@ import { useSidebar } from "../context/SidebarContext";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
-import { cn } from "../lib/utils";
+import { cn, projectRouteRef } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder";
import {
Collapsible,
@@ -28,12 +28,12 @@ import {
import type { Project } from "@paperclip/shared";
function SortableProjectItem({
- activeProjectId,
+ activeProjectRef,
isMobile,
project,
setSidebarOpen,
}: {
- activeProjectId: string | null;
+ activeProjectRef: string | null;
isMobile: boolean;
project: Project;
setSidebarOpen: (open: boolean) => void;
@@ -47,6 +47,8 @@ function SortableProjectItem({
isDragging,
} = useSortable({ id: project.id });
+ const routeRef = projectRouteRef(project);
+
return (
{
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
- activeProjectId === project.id
+ activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
@@ -110,8 +112,8 @@ export function SidebarProjects() {
userId: currentUserId,
});
- const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/);
- const activeProjectId = projectMatch?.[1] ?? null;
+ const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
+ const activeProjectRef = projectMatch?.[1] ?? null;
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
@@ -175,7 +177,7 @@ export function SidebarProjects() {
{orderedProjects.map((project: Project) => (
= {
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.",
- bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.",
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
command: "The command to execute (e.g. node, python).",
- localCommand: "Override the local CLI command (e.g. claude, /usr/local/bin/claude, codex).",
+ localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).",
args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
@@ -372,3 +380,87 @@ export function DraftNumberInput({
/>
);
}
+
+/**
+ * "Choose" button that opens a dialog explaining the user must manually
+ * type the path due to browser security limitations.
+ */
+export function ChoosePathButton() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}
+ >
+ Choose
+
+
+
+
+ Specify path manually
+
+ Browser security blocks apps from reading full local paths via a file picker.
+ Copy the absolute path and paste it into the input.
+
+
+
+
+ macOS (Finder)
+
+ Find the folder in Finder.
+ Hold Option and right-click the folder.
+ Click "Copy <folder name> as Pathname".
+ Paste the result into the path input.
+
+
+ /Users/yourname/Documents/project
+
+
+
+ Windows (File Explorer)
+
+ Find the folder in File Explorer.
+ Hold Shift and right-click the folder.
+ Click "Copy as path".
+ Paste the result into the path input.
+
+
+ C:\Users\yourname\Documents\project
+
+
+
+ Terminal fallback (macOS/Linux)
+
+ Run cd /path/to/folder.
+ Run pwd.
+ Copy the output and paste it into the path input.
+
+
+
+
+ setOpen(false)}>
+ OK
+
+
+
+
+ >
+ );
+}
+
+/**
+ * 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 (
+
+
+ {label}
+ {hint && }
+
+
{children}
+
+ );
+}
diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx
index b5ea4abd..e9933291 100644
--- a/ui/src/components/ui/button.tsx
+++ b/ui/src/components/ui/button.tsx
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,opacity] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
@@ -21,13 +21,13 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ default: "h-10 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
- icon: "size-9",
+ icon: "size-10",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
- "icon-sm": "size-8",
+ "icon-sm": "size-9",
"icon-lg": "size-10",
},
},
diff --git a/ui/src/components/ui/skeleton.tsx b/ui/src/components/ui/skeleton.tsx
index 32ea0ef7..ef338405 100644
--- a/ui/src/components/ui/skeleton.tsx
+++ b/ui/src/components/ui/skeleton.tsx
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
)
diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx
index b5160bfe..2c5f7008 100644
--- a/ui/src/components/ui/tabs.tsx
+++ b/ui/src/components/ui/tabs.tsx
@@ -62,7 +62,7 @@ function TabsTrigger({
void;
+ setSelectedCompanyId: (companyId: string, options?: CompanySelectionOptions) => void;
reloadCompanies: () => Promise;
createCompany: (data: {
name: string;
@@ -34,24 +38,8 @@ const CompanyContext = createContext(null);
export function CompanyProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
- const [selectedCompanyId, setSelectedCompanyIdState] = useState(
- () => {
- // Check URL param first (supports "open in new tab" from company rail)
- const urlParams = new URLSearchParams(window.location.search);
- const companyParam = urlParams.get("company");
- if (companyParam) {
- localStorage.setItem(STORAGE_KEY, companyParam);
- // Clean up the URL param
- urlParams.delete("company");
- const newSearch = urlParams.toString();
- const newUrl =
- window.location.pathname + (newSearch ? `?${newSearch}` : "");
- window.history.replaceState({}, "", newUrl);
- return companyParam;
- }
- return localStorage.getItem(STORAGE_KEY);
- }
- );
+ const [selectionSource, setSelectionSource] = useState("bootstrap");
+ const [selectedCompanyId, setSelectedCompanyIdState] = useState(() => localStorage.getItem(STORAGE_KEY));
const { data: companies = [], isLoading, error } = useQuery({
queryKey: queryKeys.companies.all,
@@ -83,11 +71,13 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
const next = selectableCompanies[0]!.id;
setSelectedCompanyIdState(next);
+ setSelectionSource("bootstrap");
localStorage.setItem(STORAGE_KEY, next);
}, [companies, selectedCompanyId, sidebarCompanies]);
- const setSelectedCompanyId = useCallback((companyId: string) => {
+ const setSelectedCompanyId = useCallback((companyId: string, options?: CompanySelectionOptions) => {
setSelectedCompanyIdState(companyId);
+ setSelectionSource(options?.source ?? "manual");
localStorage.setItem(STORAGE_KEY, companyId);
}, []);
@@ -121,6 +111,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
companies,
selectedCompanyId,
selectedCompany,
+ selectionSource,
loading: isLoading,
error: error as Error | null,
setSelectedCompanyId,
@@ -131,6 +122,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
companies,
selectedCompanyId,
selectedCompany,
+ selectionSource,
isLoading,
error,
setSelectedCompanyId,
diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx
index 433eed03..6bcbab0c 100644
--- a/ui/src/context/LiveUpdatesProvider.tsx
+++ b/ui/src/context/LiveUpdatesProvider.tsx
@@ -125,7 +125,7 @@ function resolveIssueToastContext(
}
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
-const AGENT_TOAST_STATUSES = new Set(["running", "idle", "error"]);
+const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
function describeIssueUpdate(details: Record | null): string | null {
@@ -178,6 +178,10 @@ function buildActivityToast(
}
if (action === "issue.updated") {
+ if (details?.reopened === true && readString(details.source) === "comment") {
+ // Reopen-via-comment emits a paired comment event; show one combined toast on the comment event.
+ return null;
+ }
const changeDesc = describeIssueUpdate(details);
const body = changeDesc
? issue.title
@@ -197,13 +201,26 @@ function buildActivityToast(
const commentId = readString(details?.commentId);
const bodySnippet = readString(details?.bodySnippet);
+ const reopened = details?.reopened === true;
+ const reopenedFrom = readString(details?.reopenedFrom);
+ const reopenedLabel = reopened
+ ? reopenedFrom
+ ? `reopened from ${reopenedFrom.replace(/_/g, " ")}`
+ : "reopened"
+ : null;
+ const title = reopened ? `${actor} reopened and commented on ${issue.ref}` : `${actor} commented on ${issue.ref}`;
+ const body = bodySnippet
+ ? reopenedLabel
+ ? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}`
+ : bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")
+ : reopenedLabel
+ ? issue.title
+ ? `${reopenedLabel} - ${issue.title}`
+ : reopenedLabel
+ : issue.title ?? undefined;
return {
- title: `${actor} commented on ${issue.ref}`,
- body: bodySnippet
- ? truncate(bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " "), 96)
- : issue.title
- ? truncate(issue.title, 96)
- : undefined,
+ title,
+ body: body ? truncate(body, 96) : undefined,
tone: "info",
action: { label: `View ${issue.ref}`, href: issue.href },
dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`,
@@ -220,14 +237,12 @@ function buildAgentStatusToast(
const status = readString(payload.status);
if (!agentId || !status || !AGENT_TOAST_STATUSES.has(status)) return null;
- const tone = status === "error" ? "error" : status === "idle" ? "success" : "info";
+ const tone = status === "error" ? "error" : "info";
const name = nameOf(agentId) ?? `Agent ${shortId(agentId)}`;
const title =
status === "running"
? `${name} started`
- : status === "idle"
- ? `${name} is idle`
- : `${name} errored`;
+ : `${name} errored`;
const agents = queryClient.getQueryData(queryKeys.agents.list(companyId));
const agent = agents?.find((a) => a.id === agentId);
diff --git a/ui/src/hooks/useCompanyPageMemory.ts b/ui/src/hooks/useCompanyPageMemory.ts
index e28a636f..d427e587 100644
--- a/ui/src/hooks/useCompanyPageMemory.ts
+++ b/ui/src/hooks/useCompanyPageMemory.ts
@@ -1,8 +1,10 @@
import { useEffect, useRef } from "react";
-import { useLocation, useNavigate } from "react-router-dom";
+import { useLocation, useNavigate } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
+import { toCompanyRelativePath } from "../lib/company-routes";
const STORAGE_KEY = "paperclip.companyPaths";
+const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
function getCompanyPaths(): Record {
try {
@@ -20,12 +22,21 @@ function saveCompanyPath(companyId: string, path: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
}
+function isRememberableCompanyPath(path: string): boolean {
+ const pathname = path.split("?")[0] ?? "";
+ const segments = pathname.split("/").filter(Boolean);
+ if (segments.length === 0) return true;
+ const [root] = segments;
+ if (GLOBAL_SEGMENTS.has(root!)) return false;
+ return true;
+}
+
/**
* Remembers the last visited page per company and navigates to it on company switch.
* Falls back to /dashboard if no page was previously visited for a company.
*/
export function useCompanyPageMemory() {
- const { selectedCompanyId } = useCompany();
+ const { selectedCompanyId, selectedCompany, selectionSource } = useCompany();
const location = useLocation();
const navigate = useNavigate();
const prevCompanyId = useRef(selectedCompanyId);
@@ -36,8 +47,9 @@ export function useCompanyPageMemory() {
const fullPath = location.pathname + location.search;
useEffect(() => {
const companyId = prevCompanyId.current;
- if (companyId) {
- saveCompanyPath(companyId, fullPath);
+ const relativePath = toCompanyRelativePath(fullPath);
+ if (companyId && isRememberableCompanyPath(relativePath)) {
+ saveCompanyPath(companyId, relativePath);
}
}, [fullPath]);
@@ -49,10 +61,14 @@ export function useCompanyPageMemory() {
prevCompanyId.current !== null &&
selectedCompanyId !== prevCompanyId.current
) {
- const paths = getCompanyPaths();
- const savedPath = paths[selectedCompanyId];
- navigate(savedPath || "/dashboard", { replace: true });
+ if (selectionSource !== "route_sync" && selectedCompany) {
+ const paths = getCompanyPaths();
+ const savedPath = paths[selectedCompanyId];
+ const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard";
+ const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard";
+ navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
+ }
}
prevCompanyId.current = selectedCompanyId;
- }, [selectedCompanyId, navigate]);
+ }, [selectedCompany, selectedCompanyId, selectionSource, navigate]);
}
diff --git a/ui/src/index.css b/ui/src/index.css
index a9a8afca..76e4e8df 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -120,12 +120,18 @@
}
html {
height: 100%;
+ -webkit-tap-highlight-color: color-mix(in oklab, var(--foreground) 20%, transparent);
}
body {
@apply bg-background text-foreground;
height: 100%;
overflow: hidden;
}
+ h1,
+ h2,
+ h3 {
+ text-wrap: balance;
+ }
/* Prevent double-tap-to-zoom on interactive elements for mobile */
a,
button,
@@ -138,6 +144,17 @@
}
}
+@media (pointer: coarse) {
+ button,
+ [role="button"],
+ input,
+ select,
+ textarea,
+ [data-slot="select-trigger"] {
+ min-height: 44px;
+ }
+}
+
/* Dark mode scrollbars */
.dark {
color-scheme: dark;
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index c83ed1d2..bc837c31 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
+import { deriveAgentUrlKey, deriveProjectUrlKey } from "@paperclip/shared";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -51,3 +52,23 @@ export function formatTokens(n: number): string {
export function issueUrl(issue: { id: string; identifier?: string | null }): string {
return `/issues/${issue.identifier ?? issue.id}`;
}
+
+/** Build an agent route URL using the short URL key when available. */
+export function agentRouteRef(agent: { id: string; urlKey?: string | null; name?: string | null }): string {
+ return agent.urlKey ?? deriveAgentUrlKey(agent.name, agent.id);
+}
+
+/** Build an agent URL using the short URL key when available. */
+export function agentUrl(agent: { id: string; urlKey?: string | null; name?: string | null }): string {
+ return `/agents/${agentRouteRef(agent)}`;
+}
+
+/** Build a project route reference using the short URL key when available. */
+export function projectRouteRef(project: { id: string; urlKey?: string | null; name?: string | null }): string {
+ return project.urlKey ?? deriveProjectUrlKey(project.name, project.id);
+}
+
+/** Build a project URL using the short URL key when available. */
+export function projectUrl(project: { id: string; urlKey?: string | null; name?: string | null }): string {
+ return `/projects/${projectRouteRef(project)}`;
+}
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index 8927cf86..ba8a4d69 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -1,6 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import { BrowserRouter } from "react-router-dom";
+import { BrowserRouter } from "@/lib/router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
import { CompanyProvider } from "./context/CompanyContext";
diff --git a/ui/src/pages/Activity.tsx b/ui/src/pages/Activity.tsx
index 17b2829f..185d2f3c 100644
--- a/ui/src/pages/Activity.tsx
+++ b/ui/src/pages/Activity.tsx
@@ -10,6 +10,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { ActivityRow } from "../components/ActivityRow";
+import { PageSkeleton } from "../components/PageSkeleton";
import {
Select,
SelectContent,
@@ -84,6 +85,10 @@ export function Activity() {
return ;
}
+ if (isLoading) {
+ return ;
+ }
+
const filtered =
data && filter !== "all"
? data.filter((e) => e.entityType === filter)
@@ -111,7 +116,6 @@ export function Activity() {
- {isLoading && Loading...
}
{error && {error.message}
}
{filtered && filtered.length === 0 && (
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index e8d7dd46..327cd1aa 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
-import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom";
+import { useParams, useNavigate, Link, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
@@ -22,6 +22,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity";
+import { PageSkeleton } from "../components/PageSkeleton";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
@@ -54,7 +55,8 @@ import {
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
-import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
+import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState } from "@paperclip/shared";
+import { agentRouteRef } from "../lib/utils";
const runStatusIcons: Record = {
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
@@ -223,8 +225,13 @@ function asNonEmptyString(value: unknown): string | null {
}
export function AgentDetail() {
- const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>();
- const { selectedCompanyId } = useCompany();
+ const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{
+ companyPrefix?: string;
+ agentId: string;
+ tab?: string;
+ runId?: string;
+ }>();
+ const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel();
const { openNewIssue } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -238,68 +245,101 @@ export function AgentDetail() {
const saveConfigActionRef = useRef<(() => void) | null>(null);
const cancelConfigActionRef = useRef<(() => void) | null>(null);
const { isMobile } = useSidebar();
+ const routeAgentRef = agentId ?? "";
+ const routeCompanyId = useMemo(() => {
+ if (!companyPrefix) return null;
+ const requestedPrefix = companyPrefix.toUpperCase();
+ return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
+ }, [companies, companyPrefix]);
+ const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
+ const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId));
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
const { data: agent, isLoading, error } = useQuery({
- queryKey: queryKeys.agents.detail(agentId!),
- queryFn: () => agentsApi.get(agentId!),
- enabled: !!agentId,
+ queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
+ queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
+ enabled: canFetchAgent,
});
+ const resolvedCompanyId = agent?.companyId ?? selectedCompanyId;
+ const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef;
+ const agentLookupRef = agent?.id ?? routeAgentRef;
const { data: runtimeState } = useQuery({
- queryKey: queryKeys.agents.runtimeState(agentId!),
- queryFn: () => agentsApi.runtimeState(agentId!),
- enabled: !!agentId,
+ queryKey: queryKeys.agents.runtimeState(agentLookupRef),
+ queryFn: () => agentsApi.runtimeState(agentLookupRef, resolvedCompanyId ?? undefined),
+ enabled: Boolean(agentLookupRef),
});
const { data: heartbeats } = useQuery({
- queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId),
- queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId),
- enabled: !!selectedCompanyId && !!agentId,
+ queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined),
+ queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined),
+ enabled: !!resolvedCompanyId && !!agent?.id,
});
const { data: allIssues } = useQuery({
- queryKey: queryKeys.issues.list(selectedCompanyId!),
- queryFn: () => issuesApi.list(selectedCompanyId!),
- enabled: !!selectedCompanyId,
+ queryKey: queryKeys.issues.list(resolvedCompanyId!),
+ queryFn: () => issuesApi.list(resolvedCompanyId!),
+ enabled: !!resolvedCompanyId,
});
const { data: allAgents } = useQuery({
- queryKey: queryKeys.agents.list(selectedCompanyId!),
- queryFn: () => agentsApi.list(selectedCompanyId!),
- enabled: !!selectedCompanyId,
+ queryKey: queryKeys.agents.list(resolvedCompanyId!),
+ queryFn: () => agentsApi.list(resolvedCompanyId!),
+ enabled: !!resolvedCompanyId,
});
- const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
+ const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agent?.id);
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
- const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId && a.status !== "terminated");
+ const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
const mobileLiveRun = useMemo(
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
[heartbeats],
);
+ useEffect(() => {
+ if (!agent) return;
+ if (routeAgentRef === canonicalAgentRef) return;
+ if (urlRunId) {
+ navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
+ return;
+ }
+ if (urlTab) {
+ navigate(`/agents/${canonicalAgentRef}/${urlTab}`, { replace: true });
+ return;
+ }
+ navigate(`/agents/${canonicalAgentRef}`, { replace: true });
+ }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, navigate]);
+
+ useEffect(() => {
+ if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
+ setSelectedCompanyId(agent.companyId, { source: "route_sync" });
+ }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
+
const agentAction = useMutation({
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => {
- if (!agentId) return Promise.reject(new Error("No agent ID"));
+ if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
switch (action) {
- case "invoke": return agentsApi.invoke(agentId);
- case "pause": return agentsApi.pause(agentId);
- case "resume": return agentsApi.resume(agentId);
- case "terminate": return agentsApi.terminate(agentId);
+ case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
+ case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
+ case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
+ case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
}
},
onSuccess: (data, action) => {
setActionError(null);
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) });
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) });
- if (selectedCompanyId) {
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
- queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(selectedCompanyId, agentId!) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
+ if (resolvedCompanyId) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
+ if (agent?.id) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
+ }
}
if (action === "invoke" && data && typeof data === "object" && "id" in data) {
- navigate(`/agents/${agentId}/runs/${(data as HeartbeatRun).id}`);
+ navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
}
},
onError: (err) => {
@@ -308,21 +348,23 @@ export function AgentDetail() {
});
const updateIcon = useMutation({
- mutationFn: (icon: string) => agentsApi.update(agentId!, { icon }),
+ mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
- if (selectedCompanyId) {
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
+ if (resolvedCompanyId) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
}
},
});
const resetTaskSession = useMutation({
- mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey),
+ mutationFn: (taskKey: string | null) =>
+ agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined),
onSuccess: () => {
setActionError(null);
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) });
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Failed to reset session");
@@ -331,12 +373,13 @@ export function AgentDetail() {
const updatePermissions = useMutation({
mutationFn: (canCreateAgents: boolean) =>
- agentsApi.updatePermissions(agentId!, { canCreateAgents }),
+ agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined),
onSuccess: () => {
setActionError(null);
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
- if (selectedCompanyId) {
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
+ if (resolvedCompanyId) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
}
},
onError: (err) => {
@@ -348,13 +391,13 @@ export function AgentDetail() {
const crumbs: { label: string; href?: string }[] = [
{ label: "Agents", href: "/agents" },
];
- const agentName = agent?.name ?? agentId ?? "Agent";
+ const agentName = agent?.name ?? routeAgentRef ?? "Agent";
if (activeView === "overview" && !urlRunId) {
crumbs.push({ label: agentName });
} else {
- crumbs.push({ label: agentName, href: `/agents/${agentId}` });
+ crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}` });
if (urlRunId) {
- crumbs.push({ label: "Runs", href: `/agents/${agentId}/runs` });
+ crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
} else if (activeView === "configure") {
crumbs.push({ label: "Configure" });
@@ -363,7 +406,7 @@ export function AgentDetail() {
}
}
setBreadcrumbs(crumbs);
- }, [setBreadcrumbs, agent, agentId, activeView, urlRunId]);
+ }, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]);
useEffect(() => {
closePanel();
@@ -378,7 +421,7 @@ export function AgentDetail() {
}, [configDirty]),
);
- if (isLoading) return Loading...
;
+ if (isLoading) return ;
if (error) return {error.message}
;
if (!agent) return null;
const isPendingApproval = agent.status === "pending_approval";
@@ -409,7 +452,7 @@ export function AgentDetail() {
openNewIssue({ assigneeAgentId: agentId })}
+ onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
>
Assign Task
@@ -447,7 +490,7 @@ export function AgentDetail() {
{mobileLiveRun && (
@@ -466,6 +509,16 @@ export function AgentDetail() {
+ {
+ navigate(`/agents/${canonicalAgentRef}/configure`);
+ setMoreOpen(false);
+ }}
+ >
+
+ Configure Agent
+
{
@@ -532,7 +585,7 @@ export function AgentDetail() {
onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving}
>
- {configSaving ? "Saving..." : "Save"}
+ {configSaving ? "Saving…" : "Save"}
@@ -558,7 +611,7 @@ export function AgentDetail() {
onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving}
>
- {configSaving ? "Saving..." : "Save"}
+ {configSaving ? "Saving…" : "Save"}
@@ -573,14 +626,16 @@ export function AgentDetail() {
runtimeState={runtimeState}
reportsToAgent={reportsToAgent ?? null}
directReports={directReports}
- agentId={agentId!}
+ agentId={agent.id}
+ agentRouteId={canonicalAgentRef}
/>
)}
{activeView === "configure" && (
@@ -631,7 +687,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
return (
-
+
{isLive && (
@@ -649,10 +705,13 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
-
+
@@ -674,7 +733,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
{summary}
)}
-
+
);
}
@@ -689,6 +748,7 @@ function AgentOverview({
reportsToAgent,
directReports,
agentId,
+ agentRouteId,
}: {
agent: Agent;
runs: HeartbeatRun[];
@@ -697,11 +757,12 @@ function AgentOverview({
reportsToAgent: Agent | null;
directReports: Agent[];
agentId: string;
+ agentRouteId: string;
}) {
return (
{/* Latest Run */}
-
+
{/* Charts */}
@@ -758,7 +819,7 @@ function AgentOverview({
{/* Configuration Summary */}
@@ -772,12 +833,12 @@ function AgentOverview({
function ConfigSummary({
agent,
- agentId,
+ agentRouteId,
reportsToAgent,
directReports,
}: {
agent: Agent;
- agentId: string;
+ agentRouteId: string;
reportsToAgent: Agent | null;
directReports: Agent[];
}) {
@@ -789,7 +850,7 @@ function ConfigSummary({
Configuration
@@ -835,7 +896,7 @@ function ConfigSummary({
{reportsToAgent ? (
@@ -852,7 +913,7 @@ function ConfigSummary({
{directReports.map((r) => (
@@ -966,6 +1027,7 @@ function CostsSection({
function AgentConfigurePage({
agent,
agentId,
+ companyId,
onDirtyChange,
onSaveActionChange,
onCancelActionChange,
@@ -974,6 +1036,7 @@ function AgentConfigurePage({
}: {
agent: Agent;
agentId: string;
+ companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
@@ -985,11 +1048,11 @@ function AgentConfigurePage({
const { data: configRevisions } = useQuery({
queryKey: queryKeys.agents.configRevisions(agent.id),
- queryFn: () => agentsApi.listConfigRevisions(agent.id),
+ queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId),
});
const rollbackConfig = useMutation({
- mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId),
+ mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
@@ -1005,10 +1068,11 @@ function AgentConfigurePage({
onCancelActionChange={onCancelActionChange}
onSavingChange={onSavingChange}
updatePermissions={updatePermissions}
+ companyId={companyId}
/>
API Keys
-
+
{/* Configuration Revisions — collapsible at the bottom */}
@@ -1069,6 +1133,7 @@ function AgentConfigurePage({
function ConfigurationTab({
agent,
+ companyId,
onDirtyChange,
onSaveActionChange,
onCancelActionChange,
@@ -1076,6 +1141,7 @@ function ConfigurationTab({
updatePermissions,
}: {
agent: Agent;
+ companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
@@ -1090,7 +1156,7 @@ function ConfigurationTab({
});
const updateAgent = useMutation({
- mutationFn: (data: Record) => agentsApi.update(agent.id, data),
+ mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
@@ -1190,7 +1256,21 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
);
}
-function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null; adapterType: string }) {
+function RunsTab({
+ runs,
+ companyId,
+ agentId,
+ agentRouteId,
+ selectedRunId,
+ adapterType,
+}: {
+ runs: HeartbeatRun[];
+ companyId: string;
+ agentId: string;
+ agentRouteId: string;
+ selectedRunId: string | null;
+ adapterType: string;
+}) {
const { isMobile } = useSidebar();
if (runs.length === 0) {
@@ -1212,20 +1292,20 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
return (
);
}
return (
{sorted.map((run) => (
-
+
))}
);
@@ -1241,7 +1321,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
)}>
{sorted.map((run) => (
-
+
))}
@@ -1249,7 +1329,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
{/* Right: run detail — natural height, page scrolls */}
{selectedRun && (
-
+
)}
@@ -1258,7 +1338,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
/* ---- Run Detail (expanded) ---- */
-function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
+function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const metrics = runMetrics(run);
@@ -1299,7 +1379,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
triggerDetail: "manual",
reason: "resume_process_lost_run",
payload: resumePayload,
- });
+ }, run.companyId);
if (!("id" in result)) {
throw new Error("Resume request was skipped because the agent is not currently invokable.");
}
@@ -1307,7 +1387,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
},
onSuccess: (resumedRun) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
- navigate(`/agents/${run.agentId}/runs/${resumedRun.id}`);
+ navigate(`/agents/${agentRouteId}/runs/${resumedRun.id}`);
},
});
@@ -1323,7 +1403,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const clearSessionsForTouchedIssues = useMutation({
mutationFn: async () => {
if (touchedIssueIds.length === 0) return 0;
- await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId)));
+ await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId, run.companyId)));
return touchedIssueIds.length;
},
onSuccess: () => {
@@ -1334,7 +1414,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
});
const runClaudeLogin = useMutation({
- mutationFn: () => agentsApi.loginWithClaude(run.agentId),
+ mutationFn: () => agentsApi.loginWithClaude(run.agentId, run.companyId),
onSuccess: (data) => {
setClaudeLoginResult(data);
},
@@ -1386,7 +1466,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
onClick={() => cancelRun.mutate()}
disabled={cancelRun.isPending}
>
- {cancelRun.isPending ? "Cancelling..." : "Cancel"}
+ {cancelRun.isPending ? "Cancelling…" : "Cancel"}
)}
{canResumeLostRun && (
@@ -1398,7 +1478,7 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
disabled={resumeRun.isPending}
>
- {resumeRun.isPending ? "Resuming..." : "Resume"}
+ {resumeRun.isPending ? "Resuming…" : "Resume"}
)}
@@ -1898,6 +1978,20 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
)}
+ {Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
+
+
Command notes
+
+ {adapterInvokePayload.commandNotes
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
+ .map((note, idx) => (
+
+ {note}
+
+ ))}
+
+
+ )}
{adapterInvokePayload.prompt !== undefined && (
Prompt
@@ -2147,7 +2241,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
/* ---- Keys Tab ---- */
-function KeysTab({ agentId }: { agentId: string }) {
+function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
const queryClient = useQueryClient();
const [newKeyName, setNewKeyName] = useState("");
const [newToken, setNewToken] = useState
(null);
@@ -2156,11 +2250,11 @@ function KeysTab({ agentId }: { agentId: string }) {
const { data: keys, isLoading } = useQuery({
queryKey: queryKeys.agents.keys(agentId),
- queryFn: () => agentsApi.listKeys(agentId),
+ queryFn: () => agentsApi.listKeys(agentId, companyId),
});
const createKey = useMutation({
- mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default"),
+ mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default", companyId),
onSuccess: (data) => {
setNewToken(data.token);
setTokenVisible(true);
@@ -2170,7 +2264,7 @@ function KeysTab({ agentId }: { agentId: string }) {
});
const revokeKey = useMutation({
- mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId),
+ mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId, companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
},
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx
index ad75ab95..89e3fdbd 100644
--- a/ui/src/pages/Agents.tsx
+++ b/ui/src/pages/Agents.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from "react";
-import { Link, useNavigate, useLocation } from "react-router-dom";
+import { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
@@ -12,7 +12,8 @@ import { StatusBadge } from "../components/StatusBadge";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
-import { relativeTime, cn } from "../lib/utils";
+import { PageSkeleton } from "../components/PageSkeleton";
+import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
@@ -121,6 +122,10 @@ export function Agents() {
return ;
}
+ if (isLoading) {
+ return ;
+ }
+
const filtered = filterAgents(agents ?? [], tab, showTerminated);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
@@ -204,7 +209,6 @@ export function Agents() {
{filtered.length} agent{filtered.length !== 1 ? "s" : ""}
)}
- {isLoading && Loading...
}
{error && {error.message}
}
{agents && agents.length === 0 && (
@@ -225,7 +229,7 @@ export function Agents() {
key={agent.id}
title={agent.name}
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
- to={`/agents/${agent.id}`}
+ to={agentUrl(agent)}
leading={
{liveRunByAgent.has(agent.id) ? (
@@ -249,7 +253,7 @@ export function Agents() {
{liveRunByAgent.has(agent.id) && (
@@ -320,7 +324,7 @@ function OrgTreeNode({
return (
@@ -337,7 +341,7 @@ function OrgTreeNode({
{liveRunByAgent.has(node.id) ? (
@@ -348,7 +352,7 @@ function OrgTreeNode({
{liveRunByAgent.has(node.id) && (
@@ -381,17 +385,17 @@ function OrgTreeNode({
}
function LiveRunIndicator({
- agentId,
+ agentRef,
runId,
liveCount,
}: {
- agentId: string;
+ agentRef: string;
runId: string;
liveCount: number;
}) {
return (
e.stopPropagation()}
>
diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx
index 49420373..413ee660 100644
--- a/ui/src/pages/ApprovalDetail.tsx
+++ b/ui/src/pages/ApprovalDetail.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
-import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
+import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
import { agentsApi } from "../api/agents";
@@ -9,6 +9,7 @@ import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
+import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react";
@@ -17,7 +18,7 @@ import { MarkdownBody } from "../components/MarkdownBody";
export function ApprovalDetail() {
const { approvalId } = useParams<{ approvalId: string }>();
- const { selectedCompanyId } = useCompany();
+ const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -31,6 +32,7 @@ export function ApprovalDetail() {
queryFn: () => approvalsApi.get(approvalId!),
enabled: !!approvalId,
});
+ const resolvedCompanyId = approval?.companyId ?? selectedCompanyId;
const { data: comments } = useQuery({
queryKey: queryKeys.approvals.comments(approvalId!),
@@ -45,11 +47,16 @@ export function ApprovalDetail() {
});
const { data: agents } = useQuery({
- queryKey: queryKeys.agents.list(approval?.companyId ?? selectedCompanyId ?? ""),
- queryFn: () => agentsApi.list(approval?.companyId ?? selectedCompanyId ?? ""),
- enabled: !!(approval?.companyId ?? selectedCompanyId),
+ queryKey: queryKeys.agents.list(resolvedCompanyId ?? ""),
+ queryFn: () => agentsApi.list(resolvedCompanyId ?? ""),
+ enabled: !!resolvedCompanyId,
});
+ useEffect(() => {
+ if (!approval?.companyId || approval.companyId === selectedCompanyId) return;
+ setSelectedCompanyId(approval.companyId, { source: "route_sync" });
+ }, [approval?.companyId, selectedCompanyId, setSelectedCompanyId]);
+
const agentNameById = useMemo(() => {
const map = new Map
();
for (const agent of agents ?? []) map.set(agent.id, agent.name);
@@ -134,7 +141,7 @@ export function ApprovalDetail() {
onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"),
});
- if (isLoading) return Loading...
;
+ if (isLoading) return ;
if (!approval) return Approval not found.
;
const payload = approval.payload as Record;
@@ -346,7 +353,7 @@ export function ApprovalDetail() {
onClick={() => addCommentMutation.mutate()}
disabled={!commentBody.trim() || addCommentMutation.isPending}
>
- {addCommentMutation.isPending ? "Posting..." : "Post comment"}
+ {addCommentMutation.isPending ? "Posting…" : "Post comment"}
diff --git a/ui/src/pages/Approvals.tsx b/ui/src/pages/Approvals.tsx
index 7fdd6ec3..24c8da0a 100644
--- a/ui/src/pages/Approvals.tsx
+++ b/ui/src/pages/Approvals.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { useNavigate, useLocation } from "react-router-dom";
+import { useNavigate, useLocation } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
import { agentsApi } from "../api/agents";
@@ -11,6 +11,7 @@ import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { ShieldCheck } from "lucide-react";
import { ApprovalCard } from "../components/ApprovalCard";
+import { PageSkeleton } from "../components/PageSkeleton";
type StatusFilter = "pending" | "all";
@@ -77,6 +78,10 @@ export function Approvals() {
return
Select a company first.
;
}
+ if (isLoading) {
+ return
;
+ }
+
return (
@@ -95,11 +100,10 @@ export function Approvals() {
- {isLoading &&
Loading...
}
{error &&
{error.message}
}
{actionError &&
{actionError}
}
- {!isLoading && filtered.length === 0 && (
+ {filtered.length === 0 && (
diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx
index 1ec01843..e562c7e8 100644
--- a/ui/src/pages/Auth.tsx
+++ b/ui/src/pages/Auth.tsx
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useNavigate, useSearchParams } from "react-router-dom";
+import { useNavigate, useSearchParams } from "@/lib/router";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
+import { AsciiArtAnimation } from "@/components/AsciiArtAnimation";
+import { Sparkles } from "lucide-react";
type AuthMode = "sign_in" | "sign_up";
@@ -59,83 +61,102 @@ export function AuthPage() {
(mode === "sign_in" || name.trim().length > 0);
if (isSessionLoading) {
- return
Loading...
;
+ return (
+
+ );
}
return (
-
-
-
- {mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
-
-
- {mode === "sign_in"
- ? "Use your email and password to access this instance."
- : "Create an account for this instance. Email confirmation is not required in v1."}
-
+
+ {/* Left half — form */}
+
+
+
+
+ Paperclip
+
-
+
+ {mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
+
+
+ {mode === "sign_in"
+ ? "Use your email and password to access this instance."
+ : "Create an account for this instance. Email confirmation is not required in v1."}
+
-
- {mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
-
{
- setError(null);
- setMode(mode === "sign_in" ? "sign_up" : "sign_in");
+
+ {mode === "sign_up" && (
+
+ Name
+ setName(event.target.value)}
+ autoComplete="name"
+ autoFocus
+ />
+
+ )}
+
+ Email
+ setEmail(event.target.value)}
+ autoComplete="email"
+ autoFocus={mode === "sign_in"}
+ />
+
+
+ Password
+ setPassword(event.target.value)}
+ autoComplete={mode === "sign_in" ? "current-password" : "new-password"}
+ />
+
+ {error &&
{error}
}
+
+ {mutation.isPending
+ ? "Working…"
+ : mode === "sign_in"
+ ? "Sign In"
+ : "Create Account"}
+
+
+
+
+ {mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
+ {
+ setError(null);
+ setMode(mode === "sign_in" ? "sign_up" : "sign_in");
+ }}
+ >
+ {mode === "sign_in" ? "Create one" : "Sign in"}
+
+
+
+ {/* Right half — ASCII art animation (hidden on mobile) */}
+
);
}
diff --git a/ui/src/pages/BoardClaim.tsx b/ui/src/pages/BoardClaim.tsx
index ab8ab7a8..334a0861 100644
--- a/ui/src/pages/BoardClaim.tsx
+++ b/ui/src/pages/BoardClaim.tsx
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Link, useParams, useSearchParams } from "react-router-dom";
+import { Link, useParams, useSearchParams } from "@/lib/router";
import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
@@ -117,7 +117,7 @@ export function BoardClaimPage() {
onClick={() => claimMutation.mutate()}
disabled={claimMutation.isPending}
>
- {claimMutation.isPending ? "Claiming..." : "Claim ownership"}
+ {claimMutation.isPending ? "Claiming…" : "Claim ownership"}
diff --git a/ui/src/pages/Companies.tsx b/ui/src/pages/Companies.tsx
index 6d1ff8f0..f5e02f32 100644
--- a/ui/src/pages/Companies.tsx
+++ b/ui/src/pages/Companies.tsx
@@ -283,7 +283,7 @@ export function Companies() {
onClick={() => deleteMutation.mutate(company.id)}
disabled={deleteMutation.isPending}
>
- {deleteMutation.isPending ? "Deleting..." : "Delete"}
+ {deleteMutation.isPending ? "Deleting…" : "Delete"}
diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx
index dd737a68..12207f09 100644
--- a/ui/src/pages/Costs.tsx
+++ b/ui/src/pages/Costs.tsx
@@ -5,6 +5,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
+import { PageSkeleton } from "../components/PageSkeleton";
import { formatCents, formatTokens } from "../lib/utils";
import { Identity } from "../components/Identity";
import { StatusBadge } from "../components/StatusBadge";
@@ -89,6 +90,10 @@ export function Costs() {
return
;
}
+ if (isLoading) {
+ return
;
+ }
+
const presetKeys: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
return (
@@ -124,7 +129,6 @@ export function Costs() {
)}
- {isLoading &&
Loading...
}
{error &&
{error.message}
}
{data && (
@@ -151,7 +155,7 @@ export function Costs() {
{data.summary.budgetCents > 0 && (
90
? "bg-red-400"
: data.summary.utilizationPercent > 70
diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx
index a50129ad..a0449c02 100644
--- a/ui/src/pages/Dashboard.tsx
+++ b/ui/src/pages/Dashboard.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
-import { Link } from "react-router-dom";
+import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { dashboardApi } from "../api/dashboard";
import { activityApi } from "../api/activity";
@@ -22,6 +22,7 @@ import { cn, formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
+import { PageSkeleton } from "../components/PageSkeleton";
import type { Agent, Issue } from "@paperclip/shared";
function getRecentIssues(issues: Issue[]): Issue[] {
@@ -177,9 +178,12 @@ export function Dashboard() {
);
}
+ if (isLoading) {
+ return
;
+ }
+
return (
- {isLoading &&
Loading...
}
{error &&
{error.message}
}
@@ -256,11 +260,11 @@ export function Dashboard() {
{/* Recent Activity */}
{recentActivity.length > 0 && (
-
+
Recent Activity
-
+
{recentActivity.map((event) => (
+
Recent Tasks
@@ -285,7 +289,7 @@ export function Dashboard() {
No tasks yet.
) : (
-
+
{recentIssues.slice(0, 10).map((issue) => (
-
+
{issue.title}
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx
index 285b1fec..b2ec4f5a 100644
--- a/ui/src/pages/DesignGuide.tsx
+++ b/ui/src/pages/DesignGuide.tsx
@@ -1038,7 +1038,7 @@ export function DesignGuide() {
diff --git a/ui/src/pages/GoalDetail.tsx b/ui/src/pages/GoalDetail.tsx
index a3132be3..0673ae90 100644
--- a/ui/src/pages/GoalDetail.tsx
+++ b/ui/src/pages/GoalDetail.tsx
@@ -1,5 +1,5 @@
import { useEffect } from "react";
-import { useParams } from "react-router-dom";
+import { useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { goalsApi } from "../api/goals";
import { projectsApi } from "../api/projects";
@@ -14,6 +14,8 @@ import { GoalTree } from "../components/GoalTree";
import { StatusBadge } from "../components/StatusBadge";
import { InlineEditor } from "../components/InlineEditor";
import { EntityRow } from "../components/EntityRow";
+import { PageSkeleton } from "../components/PageSkeleton";
+import { projectUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus } from "lucide-react";
@@ -21,7 +23,7 @@ import type { Goal, Project } from "@paperclip/shared";
export function GoalDetail() {
const { goalId } = useParams<{ goalId: string }>();
- const { selectedCompanyId } = useCompany();
+ const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openNewGoal } = useDialog();
const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -36,19 +38,25 @@ export function GoalDetail() {
queryFn: () => goalsApi.get(goalId!),
enabled: !!goalId
});
+ const resolvedCompanyId = goal?.companyId ?? selectedCompanyId;
const { data: allGoals } = useQuery({
- queryKey: queryKeys.goals.list(selectedCompanyId!),
- queryFn: () => goalsApi.list(selectedCompanyId!),
- enabled: !!selectedCompanyId
+ queryKey: queryKeys.goals.list(resolvedCompanyId!),
+ queryFn: () => goalsApi.list(resolvedCompanyId!),
+ enabled: !!resolvedCompanyId
});
const { data: allProjects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
- enabled: !!selectedCompanyId
+ queryKey: queryKeys.projects.list(resolvedCompanyId!),
+ queryFn: () => projectsApi.list(resolvedCompanyId!),
+ enabled: !!resolvedCompanyId
});
+ useEffect(() => {
+ if (!goal?.companyId || goal.companyId === selectedCompanyId) return;
+ setSelectedCompanyId(goal.companyId, { source: "route_sync" });
+ }, [goal?.companyId, selectedCompanyId, setSelectedCompanyId]);
+
const updateGoal = useMutation({
mutationFn: (data: Record) =>
goalsApi.update(goalId!, data),
@@ -56,9 +64,9 @@ export function GoalDetail() {
queryClient.invalidateQueries({
queryKey: queryKeys.goals.detail(goalId!)
});
- if (selectedCompanyId) {
+ if (resolvedCompanyId) {
queryClient.invalidateQueries({
- queryKey: queryKeys.goals.list(selectedCompanyId)
+ queryKey: queryKeys.goals.list(resolvedCompanyId)
});
}
}
@@ -66,9 +74,9 @@ export function GoalDetail() {
const uploadImage = useMutation({
mutationFn: async (file: File) => {
- if (!selectedCompanyId) throw new Error("No company selected");
+ if (!resolvedCompanyId) throw new Error("No company selected");
return assetsApi.uploadImage(
- selectedCompanyId,
+ resolvedCompanyId,
file,
`goals/${goalId ?? "draft"}`
);
@@ -102,8 +110,7 @@ export function GoalDetail() {
return () => closePanel();
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
- if (isLoading)
- return Loading...
;
+ if (isLoading) return ;
if (error) return {error.message}
;
if (!goal) return null;
@@ -176,7 +183,7 @@ export function GoalDetail() {
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
- to={`/projects/${project.id}`}
+ to={projectUrl(project)}
trailing={ }
/>
))}
diff --git a/ui/src/pages/Goals.tsx b/ui/src/pages/Goals.tsx
index 51514b2b..490bb048 100644
--- a/ui/src/pages/Goals.tsx
+++ b/ui/src/pages/Goals.tsx
@@ -7,6 +7,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { GoalTree } from "../components/GoalTree";
import { EmptyState } from "../components/EmptyState";
+import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button";
import { Target, Plus } from "lucide-react";
@@ -29,9 +30,12 @@ export function Goals() {
return ;
}
+ if (isLoading) {
+ return ;
+ }
+
return (
- {isLoading &&
Loading...
}
{error &&
{error.message}
}
{goals && goals.length === 0 && (
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index 19682b65..b32e7e5d 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
-import { Link, useLocation, useNavigate } from "react-router-dom";
+import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
import { accessApi } from "../api/access";
@@ -14,6 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState";
+import { PageSkeleton } from "../components/PageSkeleton";
import { ApprovalCard } from "../components/ApprovalCard";
import { StatusBadge } from "../components/StatusBadge";
import { timeAgo } from "../lib/timeAgo";
@@ -208,7 +209,7 @@ function FailedRunCard({
disabled={retryRun.isPending}
>
- {retryRun.isPending ? "Retrying..." : "Retry"}
+ {retryRun.isPending ? "Retrying…" : "Retry"}
{actionError}}
{!allLoaded && visibleSections.length === 0 && (
- Loading...
+
)}
{allLoaded && visibleSections.length === 0 && (
diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx
index f1d5f202..e5974e39 100644
--- a/ui/src/pages/InviteLanding.tsx
+++ b/ui/src/pages/InviteLanding.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
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 { authApi } from "../api/auth";
import { healthApi } from "../api/health";
@@ -154,12 +154,19 @@ export function InviteLandingPage() {
claimSecret?: string;
claimApiKeyPath?: string;
onboarding?: Record;
+ diagnostics?: Array<{
+ code: string;
+ level: "info" | "warn";
+ message: string;
+ hint?: string;
+ }>;
};
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
+ const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
return (
@@ -185,6 +192,19 @@ export function InviteLandingPage() {
{onboardingInstallPath &&
Install to {onboardingInstallPath}
}
)}
+ {diagnostics.length > 0 && (
+
+
Connectivity diagnostics
+ {diagnostics.map((diag, idx) => (
+
+
+ [{diag.level}] {diag.message}
+
+ {diag.hint &&
{diag.hint}
}
+
+ ))}
+
+ )}
);
@@ -276,7 +296,7 @@ export function InviteLandingPage() {
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending
- ? "Submitting..."
+ ? "Submitting…"
: invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite"
: "Submit join request"}
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx
index 45cfd750..84eb3ab4 100644
--- a/ui/src/pages/Issues.tsx
+++ b/ui/src/pages/Issues.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react";
-import { useSearchParams } from "react-router-dom";
+import { useSearchParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx
index b5715109..ea717c6d 100644
--- a/ui/src/pages/MyIssues.tsx
+++ b/ui/src/pages/MyIssues.tsx
@@ -8,6 +8,7 @@ import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
+import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate } from "../lib/utils";
import { ListTodo } from "lucide-react";
@@ -29,6 +30,10 @@ export function MyIssues() {
return ;
}
+ if (isLoading) {
+ return ;
+ }
+
// Show issues that are not assigned (user-created or unassigned)
const myIssues = (issues ?? []).filter(
(i) => !i.assigneeAgentId && !["done", "cancelled"].includes(i.status)
@@ -36,10 +41,9 @@ export function MyIssues() {
return (
- {isLoading &&
Loading...
}
{error &&
{error.message}
}
- {!isLoading && myIssues.length === 0 && (
+ {myIssues.length === 0 && (
)}
diff --git a/ui/src/pages/Org.tsx b/ui/src/pages/Org.tsx
index 2928e104..2cbc1816 100644
--- a/ui/src/pages/Org.tsx
+++ b/ui/src/pages/Org.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { Link } from "react-router-dom";
+import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
@@ -7,6 +7,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
+import { PageSkeleton } from "../components/PageSkeleton";
import { ChevronRight, GitBranch } from "lucide-react";
import { cn } from "../lib/utils";
@@ -106,9 +107,12 @@ export function Org() {
return
;
}
+ if (isLoading) {
+ return
;
+ }
+
return (
- {isLoading &&
Loading...
}
{error &&
{error.message}
}
{data && data.length === 0 && (
diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx
index 7f5cb387..c0ed8dd3 100644
--- a/ui/src/pages/OrgChart.tsx
+++ b/ui/src/pages/OrgChart.tsx
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
-import { useNavigate } from "react-router-dom";
+import { useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
+import { agentUrl } from "../lib/utils";
import { EmptyState } from "../components/EmptyState";
+import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { Network } from "lucide-react";
import type { Agent } from "@paperclip/shared";
@@ -254,7 +256,7 @@ export function OrgChart() {
}
if (isLoading) {
- return
Loading...
;
+ return
;
}
if (orgTree && orgTree.length === 0) {
@@ -287,6 +289,7 @@ export function OrgChart() {
}
setZoom(newZoom);
}}
+ aria-label="Zoom in"
>
+
@@ -303,6 +306,7 @@ export function OrgChart() {
}
setZoom(newZoom);
}}
+ aria-label="Zoom out"
>
−
@@ -321,6 +325,7 @@ export function OrgChart() {
setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 });
}}
title="Fit to screen"
+ aria-label="Fit chart to screen"
>
Fit
@@ -371,14 +376,14 @@ export function OrgChart() {
navigate(`/agents/${node.id}`)}
+ onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)}
>
{/* Agent icon + status dot */}
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx
index 1d775bc2..dfe67885 100644
--- a/ui/src/pages/ProjectDetail.tsx
+++ b/ui/src/pages/ProjectDetail.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, useRef } from "react";
-import { useParams, useNavigate, useLocation, Navigate } from "react-router-dom";
+import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { PROJECT_COLORS } from "@paperclip/shared";
+import { PROJECT_COLORS, isUuidLike } from "@paperclip/shared";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
@@ -15,15 +15,20 @@ import { ProjectProperties } from "../components/ProjectProperties";
import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge";
import { IssuesList } from "../components/IssuesList";
+import { PageSkeleton } from "../components/PageSkeleton";
+import { projectRouteRef } from "../lib/utils";
/* ── Top-level tab types ── */
type ProjectTab = "overview" | "list";
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
- const prefix = `/projects/${projectId}`;
- if (pathname === `${prefix}/overview`) return "overview";
- if (pathname.startsWith(`${prefix}/issues`)) return "list";
+ const segments = pathname.split("/").filter(Boolean);
+ const projectsIdx = segments.indexOf("projects");
+ if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null;
+ const tab = segments[projectsIdx + 2];
+ if (tab === "overview") return "overview";
+ if (tab === "issues") return "list";
return null;
}
@@ -95,7 +100,7 @@ function ColorPicker({
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 }}
aria-label="Change project color"
/>
@@ -109,7 +114,7 @@ function ColorPicker({
onSelect(color);
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
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
: "hover:ring-2 hover:ring-foreground/30"
@@ -127,20 +132,19 @@ function ColorPicker({
/* ── List (issues) tab content ── */
-function ProjectIssuesList({ projectId }: { projectId: string }) {
- const { selectedCompanyId } = useCompany();
+function ProjectIssuesList({ projectId, companyId }: { projectId: string; companyId: string }) {
const queryClient = useQueryClient();
const { data: agents } = useQuery({
- queryKey: queryKeys.agents.list(selectedCompanyId!),
- queryFn: () => agentsApi.list(selectedCompanyId!),
- enabled: !!selectedCompanyId,
+ queryKey: queryKeys.agents.list(companyId),
+ queryFn: () => agentsApi.list(companyId),
+ enabled: !!companyId,
});
const { data: liveRuns } = useQuery({
- queryKey: queryKeys.liveRuns(selectedCompanyId!),
- queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
- enabled: !!selectedCompanyId,
+ queryKey: queryKeys.liveRuns(companyId),
+ queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
+ enabled: !!companyId,
refetchInterval: 5000,
});
@@ -153,17 +157,17 @@ function ProjectIssuesList({ projectId }: { projectId: string }) {
}, [liveRuns]);
const { data: issues, isLoading, error } = useQuery({
- queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId),
- queryFn: () => issuesApi.list(selectedCompanyId!, { projectId }),
- enabled: !!selectedCompanyId,
+ queryKey: queryKeys.issues.listByProject(companyId, projectId),
+ queryFn: () => issuesApi.list(companyId, { projectId }),
+ enabled: !!companyId,
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record }) =>
issuesApi.update(id, data),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId) });
- queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
},
});
@@ -184,47 +188,87 @@ function ProjectIssuesList({ projectId }: { projectId: string }) {
/* ── Main project page ── */
export function ProjectDetail() {
- const { projectId } = useParams<{ projectId: string }>();
- const { selectedCompanyId } = useCompany();
+ const { companyPrefix, projectId, filter } = useParams<{
+ companyPrefix?: string;
+ projectId: string;
+ filter?: string;
+ }>();
+ const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
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({
- queryKey: queryKeys.projects.detail(projectId!),
- queryFn: () => projectsApi.get(projectId!),
- enabled: !!projectId,
+ queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
+ queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
+ 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 = () => {
- queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId!) });
- if (selectedCompanyId) {
- queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
+ if (resolvedCompanyId) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) });
}
};
const updateProject = useMutation({
- mutationFn: (data: Record) => projectsApi.update(projectId!, data),
+ mutationFn: (data: Record) =>
+ projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId),
onSuccess: invalidateProject,
});
const uploadImage = useMutation({
mutationFn: async (file: File) => {
- if (!selectedCompanyId) throw new Error("No company selected");
- return assetsApi.uploadImage(selectedCompanyId, file, `projects/${projectId ?? "draft"}`);
+ if (!resolvedCompanyId) throw new Error("No company selected");
+ return assetsApi.uploadImage(resolvedCompanyId, file, `projects/${projectLookupRef || "draft"}`);
},
});
useEffect(() => {
setBreadcrumbs([
{ 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(() => {
if (project) {
@@ -234,19 +278,19 @@ export function ProjectDetail() {
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
// Redirect bare /projects/:id to /projects/:id/issues
- if (projectId && activeTab === null) {
- return ;
+ if (routeProjectRef && activeTab === null) {
+ return ;
}
- if (isLoading) return Loading...
;
+ if (isLoading) return ;
if (error) return {error.message}
;
if (!project) return null;
const handleTabChange = (tab: ProjectTab) => {
if (tab === "overview") {
- navigate(`/projects/${projectId}/overview`);
+ navigate(`/projects/${canonicalProjectRef}/overview`);
} else {
- navigate(`/projects/${projectId}/issues`);
+ navigate(`/projects/${canonicalProjectRef}/issues`);
}
};
@@ -303,8 +347,8 @@ export function ProjectDetail() {
/>
)}
- {activeTab === "list" && projectId && (
-
+ {activeTab === "list" && project?.id && resolvedCompanyId && (
+
)}
);
diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx
index c4af6867..6fe80ada 100644
--- a/ui/src/pages/Projects.tsx
+++ b/ui/src/pages/Projects.tsx
@@ -8,7 +8,8 @@ import { queryKeys } from "../lib/queryKeys";
import { EntityRow } from "../components/EntityRow";
import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
-import { formatDate } from "../lib/utils";
+import { PageSkeleton } from "../components/PageSkeleton";
+import { formatDate, projectUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Hexagon, Plus } from "lucide-react";
@@ -31,6 +32,10 @@ export function Projects() {
return
;
}
+ if (isLoading) {
+ return
;
+ }
+
return (
@@ -40,7 +45,6 @@ export function Projects() {
- {isLoading &&
Loading...
}
{error &&
{error.message}
}
{projects && projects.length === 0 && (
@@ -59,7 +63,7 @@ export function Projects() {
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
- to={`/projects/${project.id}`}
+ to={projectUrl(project)}
trailing={
{project.targetDate && (