Merge pull request #613 from paperclipai/public/inbox-runs-worktree-history

Polish inbox workflows, agent runs, and worktree setup
This commit is contained in:
Dotta
2026-03-11 09:21:51 -05:00
committed by GitHub
44 changed files with 1235 additions and 465 deletions

View File

@@ -115,6 +115,28 @@ describe("worktree helpers", () => {
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
});
it("builds git worktree add args with a start point", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: false,
startPoint: "public-gh/master",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
});
it("uses start point even when a local branch with the same name exists", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: true,
startPoint: "origin/main",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
});
it("rewrites loopback auth URLs to the new port only", () => {
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");

View File

@@ -62,7 +62,9 @@ type WorktreeInitOptions = {
force?: boolean;
};
type WorktreeMakeOptions = WorktreeInitOptions;
type WorktreeMakeOptions = WorktreeInitOptions & {
startPoint?: string;
};
type WorktreeEnvOptions = {
config?: string;
@@ -166,11 +168,13 @@ export function resolveGitWorktreeAddArgs(input: {
branchName: string;
targetPath: string;
branchExists: boolean;
startPoint?: string;
}): string[] {
if (input.branchExists) {
if (input.branchExists && !input.startPoint) {
return ["worktree", "add", input.targetPath, input.branchName];
}
return ["worktree", "add", "-b", input.branchName, input.targetPath, "HEAD"];
const commitish = input.startPoint ?? "HEAD";
return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish];
}
function readPidFilePort(postmasterPidFile: string): number | null {
@@ -715,10 +719,25 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
}
mkdirSync(path.dirname(targetPath), { recursive: true });
if (opts.startPoint) {
const [remote] = opts.startPoint.split("/", 1);
try {
execFileSync("git", ["fetch", remote], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
throw new Error(
`Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`,
);
}
}
const worktreeArgs = resolveGitWorktreeAddArgs({
branchName: name,
targetPath,
branchExists: localBranchExists(sourceCwd, name),
branchExists: !opts.startPoint && localBranchExists(sourceCwd, name),
startPoint: opts.startPoint,
});
const spinner = p.spinner();
@@ -775,6 +794,7 @@ export function registerWorktreeCommands(program: Command): void {
.command("worktree:make")
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
.option("--start-point <ref>", "Remote ref to base the new branch on (e.g. origin/main)")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")

View File

@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Visibility | Full visibility to board and all agents in same company |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed |
| Recovery | No automatic reassignment; work recovery stays manual/explicit |
| Agent adapters | Built-in `process` and `http` adapters |
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
| Budget period | Monthly UTC calendar window |
@@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles:
- heartbeat trigger checks
- stuck run detection
- budget threshold checks
- stale task reporting generation
Separate queue infrastructure is not required for V1.
@@ -502,7 +501,6 @@ Dashboard payload must include:
- open/in-progress/blocked/done issue counts
- month-to-date spend and budget utilization
- pending approvals count
- stale task count
## 10.9 Error Semantics
@@ -681,7 +679,6 @@ Required UX behaviors:
- global company selector
- quick actions: pause/resume agent, create task, approve/reject request
- conflict toasts on atomic checkout failure
- clear stale-task indicators
- no silent background failures; every failed run visible in UI
## 15. Operational Requirements
@@ -780,7 +777,6 @@ A release candidate is blocked unless these pass:
- add company selector and org chart view
- add approvals and cost pages
- add operational dashboard and stale-task surfacing
## Milestone 6: Hardening and Release

View File

@@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header.
My Issues
```
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
- **My Issues** — issues created by or assigned to the board operator.
### 3.3 Work Section

View File

@@ -18,5 +18,4 @@ export interface DashboardSummary {
monthUtilizationPercent: number;
};
pendingApprovals: number;
staleTasks: number;
}

View File

@@ -1346,6 +1346,17 @@ export function agentRoutes(db: Db) {
res.json(liveRuns);
});
router.get("/heartbeat-runs/:runId", async (req, res) => {
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run) {
res.status(404).json({ error: "Heartbeat run not found" });
return;
}
assertCompanyAccess(req, run.companyId);
res.json(run);
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
assertBoard(req);
const runId = req.params.runId as string;

View File

@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { issueService } from "../services/issues.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
const issueSvc = issueService(db);
const access = accessService(db);
const dashboard = dashboardService(db);
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
joinRequests: joinRequestCount,
});
const summary = await dashboard.summary(companyId);
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
const hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
res.json(badges);
});

View File

@@ -32,19 +32,6 @@ export function dashboardService(db: Db) {
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
.then((rows) => Number(rows[0]?.count ?? 0));
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
const staleTasks = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
const agentCounts: Record<string, number> = {
active: 0,
running: 0,
@@ -107,7 +94,6 @@ export function dashboardService(db: Db) {
monthUtilizationPercent: Number(utilization.toFixed(2)),
},
pendingApprovals,
staleTasks,
};
},
};

View File

@@ -46,6 +46,69 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
const summarizedHeartbeatRunResultJson = sql<Record<string, unknown> | null>`
CASE
WHEN ${heartbeatRuns.resultJson} IS NULL THEN NULL
ELSE NULLIF(
jsonb_strip_nulls(
jsonb_build_object(
'summary', CASE
WHEN ${heartbeatRuns.resultJson} ->> 'summary' IS NULL THEN NULL
ELSE left(${heartbeatRuns.resultJson} ->> 'summary', 500)
END,
'result', CASE
WHEN ${heartbeatRuns.resultJson} ->> 'result' IS NULL THEN NULL
ELSE left(${heartbeatRuns.resultJson} ->> 'result', 500)
END,
'message', CASE
WHEN ${heartbeatRuns.resultJson} ->> 'message' IS NULL THEN NULL
ELSE left(${heartbeatRuns.resultJson} ->> 'message', 500)
END,
'error', CASE
WHEN ${heartbeatRuns.resultJson} ->> 'error' IS NULL THEN NULL
ELSE left(${heartbeatRuns.resultJson} ->> 'error', 500)
END,
'total_cost_usd', ${heartbeatRuns.resultJson} -> 'total_cost_usd',
'cost_usd', ${heartbeatRuns.resultJson} -> 'cost_usd',
'costUsd', ${heartbeatRuns.resultJson} -> 'costUsd'
)
),
'{}'::jsonb
)
END
`;
const heartbeatRunListColumns = {
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
status: heartbeatRuns.status,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
error: heartbeatRuns.error,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
exitCode: heartbeatRuns.exitCode,
signal: heartbeatRuns.signal,
usageJson: heartbeatRuns.usageJson,
resultJson: summarizedHeartbeatRunResultJson.as("resultJson"),
sessionIdBefore: heartbeatRuns.sessionIdBefore,
sessionIdAfter: heartbeatRuns.sessionIdAfter,
logStore: heartbeatRuns.logStore,
logRef: heartbeatRuns.logRef,
logBytes: heartbeatRuns.logBytes,
logSha256: heartbeatRuns.logSha256,
logCompressed: heartbeatRuns.logCompressed,
stdoutExcerpt: sql<string | null>`NULL`.as("stdoutExcerpt"),
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
errorCode: heartbeatRuns.errorCode,
externalRunId: heartbeatRuns.externalRunId,
contextSnapshot: heartbeatRuns.contextSnapshot,
createdAt: heartbeatRuns.createdAt,
updatedAt: heartbeatRuns.updatedAt,
} as const;
function appendExcerpt(prev: string, chunk: string) {
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
}
@@ -2260,9 +2323,9 @@ export function heartbeatService(db: Db) {
}
return {
list: (companyId: string, agentId?: string, limit?: number) => {
list: async (companyId: string, agentId?: string, limit?: number) => {
const query = db
.select()
.select(heartbeatRunListColumns)
.from(heartbeatRuns)
.where(
agentId

View File

@@ -1411,23 +1411,5 @@ export function issueService(db: Db) {
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
}));
},
staleCount: async (companyId: string, minutes = 60) => {
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
const result = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
isNull(issues.hiddenAt),
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
),
)
.then((rows) => rows[0]);
return Number(result?.count ?? 0);
},
};
}

View File

@@ -32,6 +32,7 @@ import { NotFoundPage } from "./pages/NotFound";
import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
@@ -138,15 +139,21 @@ function boardRoutes() {
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<Navigate to="/inbox/new" replace />} />
<Route path="inbox/new" element={<Inbox />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="*" element={<NotFoundPage scope="board" />} />
</>
);
}
function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialog();

View File

@@ -29,6 +29,7 @@ export const heartbeatsApi = {
const qs = searchParams.toString();
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
},
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
events: (runId: string, afterSeq = 0, limit = 200) =>
api.get<HeartbeatRunEvent[]>(
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,

View File

@@ -434,7 +434,7 @@ function AgentRunCard({
<div className="flex items-center gap-2 min-w-0">
{isActive ? (
<span className="relative flex h-2 w-2 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
) : (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type {
@@ -221,7 +221,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
/** Build accumulated patch and send to parent */
function handleSave() {
const handleCancel = useCallback(() => {
setOverlay({ ...emptyOverlay });
}, []);
const handleSave = useCallback(() => {
if (isCreate || !isDirty) return;
const agent = props.agent;
const patch: Record<string, unknown> = {};
@@ -248,21 +252,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
props.onSave(patch);
}
}, [isCreate, isDirty, overlay, props]);
useEffect(() => {
if (!isCreate) {
props.onDirtyChange?.(isDirty);
props.onSaveActionChange?.(() => handleSave());
props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay }));
return () => {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
};
props.onSaveActionChange?.(handleSave);
props.onCancelActionChange?.(handleCancel);
}
return;
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
useEffect(() => {
if (isCreate) return;
return () => {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
};
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
// ---- Resolve values ----
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};

View File

@@ -132,7 +132,7 @@ function SortableCompanyItem({
{hasLiveAgents && (
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-80" />
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-80" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
</span>
</span>

View File

@@ -142,6 +142,7 @@ interface IssuesListProps {
liveIssueIds?: Set<string>;
projectId?: string;
viewStateKey: string;
issueLinkState?: unknown;
initialAssignees?: string[];
initialSearch?: string;
onSearchChange?: (search: string) => void;
@@ -156,6 +157,7 @@ export function IssuesList({
liveIssueIds,
projectId,
viewStateKey,
issueLinkState,
initialAssignees,
initialSearch,
onSearchChange,
@@ -591,6 +593,7 @@ export function IssuesList({
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1"
>
{/* Status icon - left column on mobile, inline on desktop */}
@@ -625,7 +628,7 @@ export function IssuesList({
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>

View File

@@ -154,7 +154,7 @@ function KanbanCard({
</span>
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
)}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Sun } from "lucide-react";
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
@@ -177,28 +177,56 @@ export function Layout() {
};
}, [isMobile, sidebarOpen, setSidebarOpen]);
const handleMainScroll = useCallback(
(event: UIEvent<HTMLElement>) => {
if (!isMobile) return;
const updateMobileNavVisibility = useCallback((currentTop: number) => {
const delta = currentTop - lastMainScrollTop.current;
const currentTop = event.currentTarget.scrollTop;
const delta = currentTop - lastMainScrollTop.current;
if (currentTop <= 24) {
setMobileNavVisible(true);
} else if (delta > 8) {
setMobileNavVisible(false);
} else if (delta < -8) {
setMobileNavVisible(true);
}
if (currentTop <= 24) {
setMobileNavVisible(true);
} else if (delta > 8) {
setMobileNavVisible(false);
} else if (delta < -8) {
setMobileNavVisible(true);
}
lastMainScrollTop.current = currentTop;
}, []);
lastMainScrollTop.current = currentTop;
},
[isMobile],
);
useEffect(() => {
if (!isMobile) {
setMobileNavVisible(true);
lastMainScrollTop.current = 0;
return;
}
const onScroll = () => {
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [isMobile, updateMobileNavVisibility]);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = isMobile ? "visible" : "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile]);
return (
<div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
<div
className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh overflow-hidden",
)}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -287,14 +315,22 @@ export function Layout() {
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-full">
<BreadcrumbBar />
<div className="flex flex-1 min-h-0">
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<BreadcrumbBar />
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
tabIndex={-1}
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
onScroll={handleMainScroll}
className={cn(
"flex-1 p-4 md:p-6",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
)}
>
{hasUnknownCompanyPrefix ? (
<NotFoundPage

View File

@@ -117,7 +117,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
return (
<div
className={cn(
"prose prose-sm max-w-none prose-p:my-2 prose-p:leading-[1.4] prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:leading-[1.4] prose-pre:my-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-headings:my-2 prose-headings:text-sm prose-blockquote:leading-[1.4] prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-code:break-all [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
"paperclip-markdown prose prose-sm max-w-none prose-pre:whitespace-pre-wrap prose-pre:break-words prose-code:break-all",
theme === "dark" && "prose-invert",
className,
)}

View File

@@ -566,7 +566,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
contentClassName,
)}
overlayContainer={containerRef.current}
plugins={plugins}
/>

View File

@@ -18,7 +18,7 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-2xl sm:text-3xl font-semibold tracking-tight">
<p className="text-2xl sm:text-3xl font-semibold tracking-tight tabular-nums">
{value}
</p>
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">

View File

@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import {
House,
CircleDot,
@@ -8,11 +7,10 @@ import {
Users,
Inbox,
} from "lucide-react";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { useInboxBadge } from "../hooks/useInboxBadge";
interface MobileBottomNavProps {
visible: boolean;
@@ -39,12 +37,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
const location = useLocation();
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { data: sidebarBadges } = useQuery({
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const inboxBadge = useInboxBadge(selectedCompanyId);
const items = useMemo<MobileNavItem[]>(
() => [
@@ -57,10 +50,10 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
to: "/inbox",
label: "Inbox",
icon: Inbox,
badge: sidebarBadges?.inbox,
badge: inboxBadge.inbox,
},
],
[openNewIssue, sidebarBadges?.inbox],
[openNewIssue, inboxBadge.inbox],
);
return (

View File

@@ -34,6 +34,7 @@ import {
Tag,
Calendar,
Paperclip,
Loader2,
} from "lucide-react";
import { cn } from "../lib/utils";
import { extractProviderIdWithFallback } from "../lib/model-utils";
@@ -420,7 +421,7 @@ export function NewIssueDialog() {
}
function handleSubmit() {
if (!effectiveCompanyId || !title.trim()) return;
if (!effectiveCompanyId || !title.trim() || createIssue.isPending) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
modelOverride: assigneeModelOverride,
@@ -516,6 +517,11 @@ export function NewIssueDialog() {
})),
[orderedProjects],
);
const savedDraft = loadDraft();
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
const canDiscardDraft = hasDraft || hasSavedDraft;
const createIssueErrorMessage =
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
const handleProjectChange = useCallback((nextProjectId: string) => {
setProjectId(nextProjectId);
@@ -563,7 +569,7 @@ export function NewIssueDialog() {
<Dialog
open={newIssueOpen}
onOpenChange={(open) => {
if (!open) closeNewIssue();
if (!open && !createIssue.isPending) closeNewIssue();
}}
>
<DialogContent
@@ -576,7 +582,16 @@ export function NewIssueDialog() {
: "sm:max-w-lg"
)}
onKeyDown={handleKeyDown}
onEscapeKeyDown={(event) => {
if (createIssue.isPending) {
event.preventDefault();
}
}}
onPointerDownOutside={(event) => {
if (createIssue.isPending) {
event.preventDefault();
return;
}
// Radix Dialog's modal DismissableLayer calls preventDefault() on
// pointerdown events that originate outside the Dialog DOM tree.
// Popover portals render at the body level (outside the Dialog), so
@@ -654,6 +669,7 @@ export function NewIssueDialog() {
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
disabled={createIssue.isPending}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
@@ -662,6 +678,7 @@ export function NewIssueDialog() {
size="icon-xs"
className="text-muted-foreground"
onClick={() => closeNewIssue()}
disabled={createIssue.isPending}
>
<span className="text-lg leading-none">&times;</span>
</Button>
@@ -680,6 +697,7 @@ export function NewIssueDialog() {
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
readOnly={createIssue.isPending}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
@@ -998,17 +1016,36 @@ export function NewIssueDialog() {
size="sm"
className="text-muted-foreground"
onClick={discardDraft}
disabled={!hasDraft && !loadDraft()}
disabled={createIssue.isPending || !canDiscardDraft}
>
Discard Draft
</Button>
<Button
size="sm"
disabled={!title.trim() || createIssue.isPending}
onClick={handleSubmit}
>
{createIssue.isPending ? "Creating..." : "Create Issue"}
</Button>
<div className="flex items-center gap-3">
<div className="min-h-5 text-right">
{createIssue.isPending ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Creating issue...
</span>
) : createIssue.isError ? (
<span className="text-xs text-destructive">{createIssueErrorMessage}</span>
) : canDiscardDraft ? (
<span className="text-xs text-muted-foreground">Draft autosaves locally</span>
) : null}
</div>
<Button
size="sm"
className="min-w-[8.5rem] disabled:opacity-100"
disabled={!title.trim() || createIssue.isPending}
onClick={handleSubmit}
aria-busy={createIssue.isPending}
>
<span className="inline-flex items-center justify-center gap-1.5">
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
</span>
</Button>
</div>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,29 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
function resolveScrollTarget() {
const mainContent = document.getElementById("main-content");
if (mainContent instanceof HTMLElement) {
const overflowY = window.getComputedStyle(mainContent).overflowY;
const usesOwnScroll =
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
&& mainContent.scrollHeight > mainContent.clientHeight + 1;
if (usesOwnScroll) {
return { type: "element" as const, element: mainContent };
}
}
return { type: "window" as const };
}
function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
if (target.type === "element") {
return target.element.scrollHeight - target.element.scrollTop - target.element.clientHeight;
}
const scroller = document.scrollingElement ?? document.documentElement;
return scroller.scrollHeight - window.scrollY - window.innerHeight;
}
/**
* Floating scroll-to-bottom button that appears when the user is far from the
* bottom of the `#main-content` scroll container. Hides when within 300px of
* the bottom. Positioned to avoid the mobile bottom nav.
* Floating scroll-to-bottom button that follows the active page scroller.
* On desktop that is `#main-content`; on mobile it falls back to window/page scroll.
*/
export function ScrollToBottom() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = document.getElementById("main-content");
if (!el) return;
const check = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setVisible(distance > 300);
setVisible(distanceFromBottom(resolveScrollTarget()) > 300);
};
const mainContent = document.getElementById("main-content");
check();
el.addEventListener("scroll", check, { passive: true });
return () => el.removeEventListener("scroll", check);
mainContent?.addEventListener("scroll", check, { passive: true });
window.addEventListener("scroll", check, { passive: true });
window.addEventListener("resize", check);
return () => {
mainContent?.removeEventListener("scroll", check);
window.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
};
}, []);
const scroll = useCallback(() => {
const el = document.getElementById("main-content");
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
const target = resolveScrollTarget();
if (target.type === "element") {
target.element.scrollTo({ top: target.element.scrollHeight, behavior: "smooth" });
return;
}
const scroller = document.scrollingElement ?? document.documentElement;
window.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
}, []);
if (!visible) return null;

View File

@@ -17,19 +17,15 @@ import { SidebarProjects } from "./SidebarProjects";
import { SidebarAgents } from "./SidebarAgents";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button";
export function Sidebar() {
const { openNewIssue } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const { data: sidebarBadges } = useQuery({
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
@@ -80,9 +76,9 @@ export function Sidebar() {
to="/inbox"
label="Inbox"
icon={Inbox}
badge={sidebarBadges?.inbox}
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
badge={inboxBadge.inbox}
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
</div>

View File

@@ -127,7 +127,7 @@ export function SidebarAgents() {
{runCount > 0 && (
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">

View File

@@ -53,7 +53,7 @@ export function SidebarNavItem({
{liveCount != null && liveCount > 0 && (
<span className="ml-auto flex items-center gap-1.5">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>

View File

@@ -256,7 +256,7 @@ function buildJoinRequestToast(
title: `${label} wants to join`,
body: "A new join request is waiting for approval.",
tone: "info",
action: { label: "View inbox", href: "/inbox/new" },
action: { label: "View inbox", href: "/inbox/unread" },
dedupeKey: `join-request:${entityId}`,
};
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import {
computeInboxBadgeData,
getRecentTouchedIssues,
loadDismissedInboxItems,
saveDismissedInboxItems,
getUnreadTouchedIssues,
} from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
export function useDismissedInboxItems() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== "paperclip:inbox:dismissed") return;
setDismissed(loadDismissedInboxItems());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const dismiss = (id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
saveDismissedInboxItems(next);
return next;
});
};
return { dismissed, dismiss };
}
export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems();
const { data: approvals = [] } = useQuery({
queryKey: queryKeys.approvals.list(companyId!),
queryFn: () => approvalsApi.list(companyId!),
enabled: !!companyId,
});
const { data: joinRequests = [] } = useQuery({
queryKey: queryKeys.access.joinRequests(companyId!),
queryFn: async () => {
try {
return await accessApi.listJoinRequests(companyId!, "pending_approval");
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return [];
}
throw err;
}
},
enabled: !!companyId,
retry: false,
});
const { data: dashboard } = useQuery({
queryKey: queryKeys.dashboard(companyId!),
queryFn: () => dashboardApi.summary(companyId!),
enabled: !!companyId,
});
const { data: touchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
queryFn: () =>
issuesApi.list(companyId!, {
touchedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
}),
enabled: !!companyId,
});
const unreadIssues = useMemo(
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
[touchedIssues],
);
const { data: heartbeatRuns = [] } = useQuery({
queryKey: queryKeys.heartbeats(companyId!),
queryFn: () => heartbeatsApi.list(companyId!),
enabled: !!companyId,
});
return useMemo(
() =>
computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
dismissed,
}),
[approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed],
);
}

View File

@@ -123,7 +123,7 @@
-webkit-tap-highlight-color: color-mix(in oklab, var(--foreground) 20%, transparent);
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
height: 100%;
overflow: hidden;
}
@@ -426,6 +426,121 @@
font-weight: 500;
}
.paperclip-markdown {
color: var(--foreground);
font-size: 0.9375rem;
line-height: 1.6;
}
.paperclip-markdown > :first-child {
margin-top: 0;
}
.paperclip-markdown > :last-child {
margin-bottom: 0;
}
.paperclip-markdown :where(p, ul, ol, blockquote, pre, table) {
margin-top: 0.7rem;
margin-bottom: 0.7rem;
}
.paperclip-markdown :where(ul, ol) {
padding-left: 1.15rem;
}
.paperclip-markdown ul {
list-style-type: disc;
}
.paperclip-markdown ol {
list-style-type: decimal;
}
.paperclip-markdown li {
margin: 0.14rem 0;
padding-left: 0.2rem;
}
.paperclip-markdown li > :where(p, ul, ol) {
margin-top: 0.3rem;
margin-bottom: 0.3rem;
}
.paperclip-markdown li::marker {
color: var(--muted-foreground);
}
.paperclip-markdown :where(h1, h2, h3, h4) {
margin-top: 1.15rem;
margin-bottom: 0.45rem;
color: var(--foreground);
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.3;
}
.paperclip-markdown h1 {
font-size: 1.5rem;
}
.paperclip-markdown h2 {
font-size: 1.25rem;
}
.paperclip-markdown h3 {
font-size: 1.05rem;
}
.paperclip-markdown h4 {
font-size: 0.95rem;
}
.paperclip-markdown :where(strong, b) {
color: var(--foreground);
font-weight: 600;
}
.paperclip-markdown a {
color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
text-decoration: none;
}
.paperclip-markdown a:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
.dark .paperclip-markdown a {
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
}
.paperclip-markdown blockquote {
margin-left: 0;
padding-left: 0.95rem;
border-left: 0.24rem solid color-mix(in oklab, var(--border) 84%, var(--muted-foreground) 16%);
color: var(--muted-foreground);
}
.paperclip-markdown hr {
margin: 1.25rem 0;
border-color: var(--border);
}
.paperclip-markdown img {
border-radius: calc(var(--radius) + 2px);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
}
.paperclip-markdown table {
width: 100%;
}
.paperclip-markdown th {
font-weight: 600;
text-align: left;
}
.paperclip-mermaid {
margin: 0.5rem 0;
padding: 0.45rem 0.55rem;
@@ -476,25 +591,21 @@ a.paperclip-project-mention-chip {
white-space: nowrap;
}
/* Keep MDXEditor popups above app dialogs when editor is inside a modal. */
.paperclip-mdxeditor-scope [class*="_dialogOverlay_"],
.paperclip-mdxeditor [class*="_dialogOverlay_"] {
/* Keep MDXEditor popups above app dialogs, even when they portal to <body>. */
[class*="_popupContainer_"] {
z-index: 81 !important;
}
[class*="_dialogOverlay_"] {
z-index: 80;
}
.paperclip-mdxeditor-scope [class*="_dialogContent_"],
.paperclip-mdxeditor-scope [class*="_largeDialogContent_"],
.paperclip-mdxeditor-scope [class*="_popoverContent_"],
.paperclip-mdxeditor-scope [class*="_linkDialogPopoverContent_"],
.paperclip-mdxeditor-scope [class*="_tableColumnEditorPopoverContent_"],
.paperclip-mdxeditor-scope [class*="_toolbarButtonDropdownContainer_"],
.paperclip-mdxeditor-scope [class*="_toolbarNodeKindSelectContainer_"],
.paperclip-mdxeditor [class*="_dialogContent_"],
.paperclip-mdxeditor [class*="_largeDialogContent_"],
.paperclip-mdxeditor [class*="_popoverContent_"],
.paperclip-mdxeditor [class*="_linkDialogPopoverContent_"],
.paperclip-mdxeditor [class*="_tableColumnEditorPopoverContent_"],
.paperclip-mdxeditor [class*="_toolbarButtonDropdownContainer_"],
.paperclip-mdxeditor [class*="_toolbarNodeKindSelectContainer_"] {
[class*="_dialogContent_"],
[class*="_largeDialogContent_"],
[class*="_popoverContent_"],
[class*="_linkDialogPopoverContent_"],
[class*="_tableColumnEditorPopoverContent_"],
[class*="_toolbarButtonDropdownContainer_"],
[class*="_toolbarNodeKindSelectContainer_"] {
z-index: 81 !important;
}

250
ui/src/lib/inbox.test.ts Normal file
View File

@@ -0,0 +1,250 @@
// @vitest-environment node
import { beforeEach, describe, expect, it } from "vitest";
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
computeInboxBadgeData,
getRecentTouchedIssues,
getUnreadTouchedIssues,
loadLastInboxTab,
RECENT_ISSUES_LIMIT,
saveLastInboxTab,
} from "./inbox";
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
},
configurable: true,
});
function makeApproval(status: Approval["status"]): Approval {
return {
id: `approval-${status}`,
companyId: "company-1",
type: "hire_agent",
requestedByAgentId: null,
requestedByUserId: null,
status,
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
}
function makeJoinRequest(id: string): JoinRequest {
return {
id,
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestEmailSnapshot: null,
requestIp: "127.0.0.1",
requestingUserId: null,
agentName: null,
adapterType: null,
capabilities: null,
agentDefaultsPayload: null,
claimSecretExpiresAt: null,
claimSecretConsumedAt: null,
createdAgentId: null,
approvedByUserId: null,
approvedAt: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
}
function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, agentId = "agent-1"): HeartbeatRun {
return {
id,
companyId: "company-1",
agentId,
invocationSource: "assignment",
triggerDetail: null,
status,
error: null,
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: null,
resultJson: null,
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
errorCode: null,
externalRunId: null,
stdoutExcerpt: null,
stderrExcerpt: null,
contextSnapshot: null,
startedAt: new Date(createdAt),
finishedAt: null,
createdAt: new Date(createdAt),
updatedAt: new Date(createdAt),
};
}
function makeIssue(id: string, isUnreadForMe: boolean): Issue {
return {
id,
companyId: "company-1",
projectId: null,
goalId: null,
parentId: null,
title: `Issue ${id}`,
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
identifier: `PAP-${id}`,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"),
lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"),
isUnreadForMe,
};
}
const dashboard: DashboardSummary = {
companyId: "company-1",
agents: {
active: 1,
running: 0,
paused: 0,
error: 1,
},
tasks: {
open: 1,
inProgress: 0,
blocked: 0,
done: 0,
},
costs: {
monthSpendCents: 900,
monthBudgetCents: 1000,
monthUtilizationPercent: 90,
},
pendingApprovals: 1,
};
describe("inbox helpers", () => {
beforeEach(() => {
storage.clear();
});
it("counts the same inbox sources the badge uses", () => {
const result = computeInboxBadgeData({
approvals: [makeApproval("pending"), makeApproval("approved")],
joinRequests: [makeJoinRequest("join-1")],
dashboard,
heartbeatRuns: [
makeRun("run-old", "failed", "2026-03-11T00:00:00.000Z"),
makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
],
unreadIssues: [makeIssue("1", true)],
dismissed: new Set<string>(),
});
expect(result).toEqual({
inbox: 6,
approvals: 1,
failedRuns: 2,
joinRequests: 1,
unreadTouchedIssues: 1,
alerts: 1,
});
});
it("drops dismissed runs and alerts from the computed badge", () => {
const result = computeInboxBadgeData({
approvals: [],
joinRequests: [],
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
unreadIssues: [],
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
});
expect(result).toEqual({
inbox: 0,
approvals: 0,
failedRuns: 0,
joinRequests: 0,
unreadTouchedIssues: 0,
alerts: 0,
});
});
it("keeps read issues in the touched list but excludes them from unread counts", () => {
const issues = [makeIssue("1", true), makeIssue("2", false)];
expect(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]);
expect(issues).toHaveLength(2);
});
it("limits recent touched issues before unread badge counting", () => {
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
const issue = makeIssue(String(index + 1), index < 3);
issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
return issue;
});
const recentIssues = getRecentTouchedIssues(issues);
expect(recentIssues).toHaveLength(RECENT_ISSUES_LIMIT);
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
});
it("defaults the remembered inbox tab to recent and persists all", () => {
localStorage.clear();
expect(loadLastInboxTab()).toBe("recent");
saveLastInboxTab("all");
expect(loadLastInboxTab()).toBe("all");
});
it("maps legacy new-tab storage to recent", () => {
localStorage.setItem("paperclip:inbox:last-tab", "new");
expect(loadLastInboxTab()).toBe("recent");
});
});

150
ui/src/lib/inbox.ts Normal file
View File

@@ -0,0 +1,150 @@
import type {
Approval,
DashboardSummary,
HeartbeatRun,
Issue,
JoinRequest,
} from "@paperclipai/shared";
export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export type InboxTab = "recent" | "unread" | "all";
export interface InboxBadgeData {
inbox: number;
approvals: number;
failedRuns: number;
joinRequests: number;
unreadTouchedIssues: number;
alerts: number;
}
export function loadDismissedInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveDismissedInboxItems(ids: Set<string>) {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
} catch {
// Ignore localStorage failures.
}
}
export function loadLastInboxTab(): InboxTab {
try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
if (raw === "all" || raw === "unread" || raw === "recent") return raw;
if (raw === "new") return "recent";
return "recent";
} catch {
return "recent";
}
}
export function saveLastInboxTab(tab: InboxTab) {
try {
localStorage.setItem(INBOX_LAST_TAB_KEY, tab);
} catch {
// Ignore localStorage failures.
}
}
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
}
export function normalizeTimestamp(value: string | Date | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function issueLastActivityTimestamp(issue: Issue): number {
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
const updatedAt = normalizeTimestamp(issue.updatedAt);
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
return updatedAt;
}
export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number {
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
if (activityDiff !== 0) return activityDiff;
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
}
export function getRecentTouchedIssues(issues: Issue[]): Issue[] {
return [...issues].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT);
}
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
return issues.filter((issue) => issue.isUnreadForMe);
}
export function computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
dismissed,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
unreadIssues: Issue[];
dismissed: Set<string>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter((approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
).length;
const unreadTouchedIssues = unreadIssues.length;
const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
const showAggregateAgentError =
agentErrorCount > 0 &&
failedRuns === 0 &&
!dismissed.has("alert:agent-errors");
const showBudgetAlert =
monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
approvals: actionableApprovals,
failedRuns,
joinRequests: joinRequests.length,
unreadTouchedIssues,
alerts,
};
}

View File

@@ -0,0 +1,24 @@
type IssueDetailBreadcrumb = {
label: string;
href: string;
};
type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
};
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<IssueDetailBreadcrumb>;
return typeof candidate.label === "string" && typeof candidate.href === "string";
}
export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState {
return { issueDetailBreadcrumb: { label, href } };
}
export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null {
if (typeof state !== "object" || state === null) return null;
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
return isIssueDetailBreadcrumb(candidate) ? candidate : null;
}

View File

@@ -68,6 +68,7 @@ export const queryKeys = {
["costs", companyId, from, to] as const,
heartbeats: (companyId: string, agentId?: string) =>
["heartbeats", companyId, agentId] as const,
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
runIssues: (runId: string) => ["run-issues", runId] as const,
org: (companyId: string) => ["org", companyId] as const,

View File

@@ -311,7 +311,12 @@ export function AgentDetail() {
}
return;
}
const canonicalTab = activeView === "configuration" ? "configuration" : "dashboard";
const canonicalTab =
activeView === "configuration"
? "configuration"
: activeView === "runs"
? "runs"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
return;
@@ -437,7 +442,7 @@ export function AgentDetail() {
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
}
const isPendingApproval = agent.status === "pending_approval";
const showConfigActionBar = activeView === "configuration" && configDirty;
const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving);
return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
@@ -506,7 +511,7 @@ export function AgentDetail() {
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
@@ -558,15 +563,16 @@ export function AgentDetail() {
{!urlRunId && (
<Tabs
value={activeView === "configuration" ? "configuration" : "dashboard"}
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
>
<PageTabBar
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "configuration", label: "Configuration" },
{ value: "runs", label: "Runs" },
]}
value={activeView === "configuration" ? "configuration" : "dashboard"}
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
/>
</Tabs>
@@ -707,7 +713,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
<h3 className="flex items-center gap-2 text-sm font-medium">
{isLive && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
</span>
)}
@@ -851,7 +857,7 @@ function CostsSection({
<div className="space-y-4">
{runtimeState && (
<div className="border border-border rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 tabular-nums">
<div>
<span className="text-xs text-muted-foreground block">Input tokens</span>
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalInputTokens)}</span>
@@ -890,9 +896,9 @@ function CostsSection({
<tr key={run.id} className="border-b border-border last:border-b-0">
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
<td className="px-3 py-2 text-right">{formatTokens(Number(u.input_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right">{formatTokens(Number(u.output_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right">
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.input_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.output_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">
{(u.cost_usd || u.total_cost_usd)
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
: "-"
@@ -1037,6 +1043,8 @@ function ConfigurationTab({
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
}) {
const queryClient = useQueryClient();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
const lastAgentRef = useRef(agent);
const { data: adapterModels } = useQuery({
queryKey:
@@ -1049,16 +1057,31 @@ function ConfigurationTab({
const updateAgent = useMutation({
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
onMutate: () => {
setAwaitingRefreshAfterSave(true);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
},
onError: () => {
setAwaitingRefreshAfterSave(false);
},
});
useEffect(() => {
onSavingChange(updateAgent.isPending);
}, [onSavingChange, updateAgent.isPending]);
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
setAwaitingRefreshAfterSave(false);
}
lastAgentRef.current = agent;
}, [agent, awaitingRefreshAfterSave]);
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
useEffect(() => {
onSavingChange(isConfigSaving);
}, [onSavingChange, isConfigSaving]);
return (
<div className="space-y-6">
@@ -1066,7 +1089,7 @@ function ConfigurationTab({
mode="edit"
agent={agent}
onSave={(patch) => updateAgent.mutate(patch)}
isSaving={updateAgent.isPending}
isSaving={isConfigSaving}
adapterModels={adapterModels}
onDirtyChange={onDirtyChange}
onSaveActionChange={onSaveActionChange}
@@ -1140,7 +1163,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
</span>
)}
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground">
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground tabular-nums">
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
</div>
@@ -1231,9 +1254,15 @@ function RunsTab({
/* ---- Run Detail (expanded) ---- */
function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: hydratedRun } = useQuery({
queryKey: queryKeys.runDetail(initialRun.id),
queryFn: () => heartbeatsApi.get(initialRun.id),
enabled: Boolean(initialRun.id),
});
const run = hydratedRun ?? initialRun;
const metrics = runMetrics(run);
const [sessionOpen, setSessionOpen] = useState(false);
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
@@ -1510,7 +1539,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen
{/* Right column: metrics */}
{hasMetrics && (
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center">
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center tabular-nums">
<div>
<div className="text-xs text-muted-foreground">Input</div>
<div className="text-sm font-medium font-mono">{formatTokens(metrics.input)}</div>
@@ -2109,7 +2138,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{isLive && (
<span className="flex items-center gap-1 text-xs text-cyan-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
</span>
Live

View File

@@ -398,7 +398,7 @@ function LiveRunIndicator({
onClick={(e) => e.stopPropagation()}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">

View File

@@ -244,7 +244,7 @@ export function Companies() {
{issueCount} {issueCount === 1 ? "issue" : "issues"}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 tabular-nums">
<DollarSign className="h-3.5 w-3.5" />
<span>
{formatCents(company.spentMonthlyCents)}

View File

@@ -144,7 +144,7 @@ export function Costs() {
</p>
)}
</div>
<p className="text-2xl font-bold">
<p className="text-2xl font-bold tabular-nums">
{formatCents(data.summary.spendCents)}{" "}
<span className="text-base font-normal text-muted-foreground">
{data.summary.budgetCents > 0
@@ -192,7 +192,7 @@ export function Costs() {
<StatusBadge status="terminated" />
)}
</div>
<div className="text-right shrink-0 ml-2">
<div className="text-right shrink-0 ml-2 tabular-nums">
<span className="font-medium block">{formatCents(row.costCents)}</span>
<span className="text-xs text-muted-foreground block">
in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok
@@ -229,7 +229,7 @@ export function Costs() {
<span className="truncate">
{row.projectName ?? row.projectId ?? "Unattributed"}
</span>
<span className="font-medium">{formatCents(row.costCents)}</span>
<span className="font-medium tabular-nums">{formatCents(row.costCents)}</span>
</div>
))}
</div>

View File

@@ -255,7 +255,7 @@ export function Dashboard() {
to="/approvals"
description={
<span>
{data.staleTasks} stale tasks
Awaiting board review
</span>
}
/>

View File

@@ -1061,7 +1061,7 @@ export function DesignGuide() {
<div className="text-foreground">[12:00:17] INFO Reconnected successfully</div>
<div className="flex items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-cyan-400 animate-ping" />
<span className="absolute inline-flex h-full w-full rounded-full bg-cyan-400 animate-pulse" />
<span className="inline-flex h-full w-full rounded-full bg-cyan-400" />
</span>
<span className="text-cyan-400">Live</span>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
@@ -11,6 +11,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState";
@@ -31,7 +32,6 @@ import {
import {
Inbox as InboxIcon,
AlertTriangle,
Clock,
ArrowUpRight,
XCircle,
X,
@@ -40,59 +40,29 @@ import {
import { Identity } from "../components/Identity";
import { PageTabBar } from "../components/PageTabBar";
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
ACTIONABLE_APPROVAL_STATUSES,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
type InboxTab,
saveLastInboxTab,
} from "../lib/inbox";
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
const RECENT_ISSUES_LIMIT = 100;
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
type InboxTab = "new" | "all";
type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts"
| "stale_work";
| "alerts";
type InboxApprovalFilter = "all" | "actionable" | "resolved";
type SectionKey =
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts"
| "stale_work";
const DISMISSED_KEY = "paperclip:inbox:dismissed";
function loadDismissed(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
function saveDismissed(ids: Set<string>) {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
}
function useDismissedItems() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissed);
const dismiss = useCallback((id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
saveDismissed(next);
return next;
});
}, []);
return { dismissed, dismiss };
}
| "alerts";
const RUN_SOURCE_LABELS: Record<string, string> = {
timer: "Scheduled",
@@ -101,32 +71,6 @@ const RUN_SOURCE_LABELS: Record<string, string> = {
automation: "Automation",
};
function getStaleIssues(issues: Issue[]): Issue[] {
const now = Date.now();
return issues
.filter(
(i) =>
["in_progress", "todo"].includes(i.status) &&
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS,
)
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
}
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
}
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@@ -137,23 +81,6 @@ function runFailureMessage(run: HeartbeatRun): string {
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
}
function normalizeTimestamp(value: string | Date | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
function issueLastActivityTimestamp(issue: Issue): number {
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
const updatedAt = normalizeTimestamp(issue.updatedAt);
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
return updatedAt;
}
function readIssueIdFromRun(run: HeartbeatRun): string | null {
const context = run.contextSnapshot;
if (!context) return null;
@@ -171,11 +98,13 @@ function FailedRunCard({
run,
issueById,
agentName: linkedAgentName,
issueLinkState,
onDismiss,
}: {
run: HeartbeatRun;
issueById: Map<string, Issue>;
agentName: string | null;
issueLinkState: unknown;
onDismiss: () => void;
}) {
const queryClient = useQueryClient();
@@ -227,6 +156,7 @@ function FailedRunCard({
{issue ? (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="block truncate text-sm font-medium transition-colors hover:text-foreground no-underline text-inherit"
>
<span className="font-mono text-muted-foreground mr-1.5">
@@ -311,10 +241,19 @@ export function Inbox() {
const [actionError, setActionError] = useState<string | null>(null);
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedItems();
const { dismissed, dismiss } = useDismissedInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "new";
const tab: InboxTab = pathSegment === "all" ? "all" : "new";
const pathSegment = location.pathname.split("/").pop() ?? "recent";
const tab: InboxTab =
pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent";
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Inbox",
`${location.pathname}${location.search}${location.hash}`,
),
[location.pathname, location.search, location.hash],
);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -326,6 +265,10 @@ export function Inbox() {
setBreadcrumbs([{ label: "Inbox" }]);
}, [setBreadcrumbs]);
useEffect(() => {
saveLastInboxTab(tab);
}, [tab]);
const {
data: approvals,
isLoading: isApprovalsLoading,
@@ -385,22 +328,10 @@ export function Inbox() {
enabled: !!selectedCompanyId,
});
const staleIssues = useMemo(
() => (issues ? getStaleIssues(issues) : []).filter((i) => !dismissed.has(`stale:${i.id}`)),
[issues, dismissed],
);
const sortByMostRecentActivity = useCallback(
(a: Issue, b: Issue) => {
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
if (activityDiff !== 0) return activityDiff;
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
},
[],
);
const touchedIssues = useMemo(
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
[sortByMostRecentActivity, touchedIssuesRaw],
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const unreadTouchedIssues = useMemo(
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
[touchedIssues],
);
const agentById = useMemo(() => {
@@ -500,17 +431,20 @@ export function Inbox() {
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const invalidateInboxIssueQueries = () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
};
const markReadMutation = useMutation({
mutationFn: (id: string) => issuesApi.markRead(id),
onMutate: (id) => {
setFadingOutIssues((prev) => new Set(prev).add(id));
},
onSuccess: () => {
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
}
invalidateInboxIssueQueries();
},
onSettled: (_data, _error, id) => {
setTimeout(() => {
@@ -523,6 +457,31 @@ export function Inbox() {
},
});
const markAllReadMutation = useMutation({
mutationFn: async (issueIds: string[]) => {
await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId)));
},
onMutate: (issueIds) => {
setFadingOutIssues((prev) => {
const next = new Set(prev);
for (const issueId of issueIds) next.add(issueId);
return next;
});
},
onSuccess: () => {
invalidateInboxIssueQueries();
},
onSettled: (_data, _error, issueIds) => {
setTimeout(() => {
setFadingOutIssues((prev) => {
const next = new Set(prev);
for (const issueId of issueIds) next.delete(issueId);
return next;
});
}, 300);
},
});
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
@@ -535,7 +494,6 @@ export function Inbox() {
dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert;
const hasStale = staleIssues.length > 0;
const hasJoinRequests = joinRequests.length > 0;
const hasTouchedIssues = touchedIssues.length > 0;
@@ -547,25 +505,26 @@ export function Inbox() {
const showFailedRunsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
const approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals;
const showTouchedSection =
tab === "all"
? showTouchedCategory && hasTouchedIssues
: tab === "unread"
? unreadTouchedIssues.length > 0
: hasTouchedIssues;
const showJoinRequestsSection =
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
const showApprovalsSection =
tab === "new"
? actionableApprovals.length > 0
: showApprovalsCategory && filteredAllApprovals.length > 0;
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
const showApprovalsSection = tab === "all"
? showApprovalsCategory && filteredAllApprovals.length > 0
: actionableApprovals.length > 0;
const showFailedRunsSection =
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures;
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts;
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures;
const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts;
const visibleSections = [
showFailedRunsSection ? "failed_runs" : null,
showAlertsSection ? "alerts" : null,
showStaleSection ? "stale_work" : null,
showApprovalsSection ? "approvals" : null,
showJoinRequestsSection ? "join_requests" : null,
showTouchedSection ? "issues_i_touched" : null,
@@ -580,24 +539,43 @@ export function Inbox() {
!isRunsLoading;
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
const unreadIssueIds = unreadTouchedIssues
.filter((issue) => !fadingOutIssues.has(issue.id))
.map((issue) => issue.id);
const canMarkAllRead = unreadIssueIds.length > 0;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value === "all" ? "all" : "new"}`)}>
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "new",
label: "New",
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
{tab === "all" && (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{canMarkAllRead && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
disabled={markAllReadMutation.isPending}
>
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
</Button>
)}
{tab === "all" && (
<>
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
@@ -612,7 +590,6 @@ export function Inbox() {
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
<SelectItem value="alerts">Alerts</SelectItem>
<SelectItem value="stale_work">Stale work</SelectItem>
</SelectContent>
</Select>
@@ -631,8 +608,9 @@ export function Inbox() {
</SelectContent>
</Select>
)}
</div>
)}
</>
)}
</div>
</div>
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
@@ -646,9 +624,11 @@ export function Inbox() {
<EmptyState
icon={InboxIcon}
message={
tab === "new"
? "No issues you're involved in yet."
: "No inbox items match these filters."
tab === "unread"
? "No new inbox items."
: tab === "recent"
? "No recent inbox items."
: "No inbox items match these filters."
}
/>
)}
@@ -658,7 +638,7 @@ export function Inbox() {
{showSeparatorBefore("approvals") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{tab === "new" ? "Approvals Needing Action" : "Approvals"}
{tab === "unread" ? "Approvals Needing Action" : "Approvals"}
</h3>
<div className="grid gap-3">
{approvalsToRender.map((approval) => (
@@ -749,6 +729,7 @@ export function Inbox() {
run={run}
issueById={issueById}
agentName={agentName(run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${run.id}`)}
/>
))}
@@ -815,165 +796,67 @@ export function Inbox() {
</>
)}
{showStaleSection && (
<>
{showSeparatorBefore("stale_work") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Stale Work
</h3>
<div className="divide-y divide-border border border-border">
{staleIssues.map((issue) => (
<div
key={issue.id}
className="group/stale relative flex items-start gap-2 overflow-hidden px-3 py-3 transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
{/* Status icon - left column on mobile; Clock icon on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground hidden sm:block sm:mt-0" />
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-1 no-underline text-inherit sm:flex-row sm:items-center sm:gap-3"
>
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="shrink-0 text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId &&
(() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
) : null;
})()}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="shrink-0 text-xs text-muted-foreground sm:order-last">
updated {timeAgo(issue.updatedAt)}
</span>
</span>
</Link>
<button
type="button"
onClick={() => dismiss(`stale:${issue.id}`)}
className="mt-0.5 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100 sm:mt-0"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
</>
)}
{showTouchedSection && (
<>
{showSeparatorBefore("issues_i_touched") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
My Recent Issues
</h3>
<div className="divide-y divide-border border border-border">
{touchedIssues.map((issue) => {
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
{/* Status icon - left column on mobile, inline on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{(isUnread || isFading) ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="hidden sm:inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={`h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</span>
) : (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0" />
)}
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="text-xs text-muted-foreground sm:hidden">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:order-last">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</span>
</span>
{/* Unread dot - right side, vertically centered (mobile only; desktop keeps inline) */}
{(isUnread || isFading) && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{(isUnread || isFading) ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="shrink-0 self-center cursor-pointer sm:hidden"
aria-label="Mark as read"
>
<span
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</span>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
<span className="inline-flex shrink-0 self-center"><PriorityIcon priority={issue.priority} /></span>
<span className="inline-flex shrink-0 self-center"><StatusIcon status={issue.status} /></span>
<span className="shrink-0 self-center text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="min-w-0 flex-1 text-sm">
<span className="line-clamp-2 min-w-0 sm:line-clamp-1 sm:block sm:truncate">
{issue.title}
</span>
)}
</span>
<span className="hidden shrink-0 self-center text-xs text-muted-foreground sm:block">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</Link>
);
})}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { activityApi } from "../api/activity";
@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor";
@@ -150,6 +151,7 @@ export function IssueDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const [moreOpen, setMoreOpen] = useState(false);
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const [detailTab, setDetailTab] = useState("comments");
@@ -213,6 +215,10 @@ export function IssueDetail() {
});
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
[location.state],
);
// Filter out runs already shown by the live widget to avoid duplication
const timelineRuns = useMemo(() => {
@@ -468,17 +474,17 @@ export function IssueDetail() {
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
{ label: "Issues", href: "/issues" },
sourceBreadcrumb,
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
]);
}, [setBreadcrumbs, issue, issueId, hasLiveRuns]);
}, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]);
// Redirect to identifier-based URL if navigated via UUID
useEffect(() => {
if (issue?.identifier && issueId !== issue.identifier) {
navigate(`/issues/${issue.identifier}`, { replace: true });
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
}
}, [issue, issueId, navigate]);
}, [issue, issueId, navigate, location.state]);
useEffect(() => {
if (!issue?.id) return;
@@ -524,6 +530,7 @@ export function IssueDetail() {
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
<Link
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
state={location.state}
className="hover:text-foreground transition-colors truncate max-w-[200px]"
title={ancestor.title}
>
@@ -558,7 +565,7 @@ export function IssueDetail() {
{hasLiveRuns && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
</span>
Live
@@ -661,7 +668,7 @@ export function IssueDetail() {
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutate({ description })}
as="p"
className="text-sm text-muted-foreground"
className="text-[15px] leading-7 text-foreground"
placeholder="Add a description..."
multiline
mentions={mentionOptions}
@@ -800,6 +807,7 @@ export function IssueDetail() {
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
state={location.state}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
@@ -893,7 +901,7 @@ export function IssueDetail() {
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useCallback, useRef } from "react";
import { useSearchParams } from "@/lib/router";
import { useLocation, useSearchParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
@@ -7,6 +7,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { CircleDot } from "lucide-react";
@@ -14,6 +15,7 @@ import { CircleDot } from "lucide-react";
export function Issues() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const location = useLocation();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
@@ -63,6 +65,15 @@ export function Issues() {
return ids;
}, [liveRuns]);
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Issues",
`${location.pathname}${location.search}${location.hash}`,
),
[location.pathname, location.search, location.hash],
);
useEffect(() => {
setBreadcrumbs([{ label: "Issues" }]);
}, [setBreadcrumbs]);
@@ -93,6 +104,7 @@ export function Issues() {
agents={agents}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:issues-view"
issueLinkState={issueLinkState}
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
initialSearch={initialSearch}
onSearchChange={handleSearchChange}

View File

@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
environment: "node",
},
});