[codex] Polish issue board workflows (#4224)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Human operators supervise that work through issue lists, issue
detail, comments, inbox groups, markdown references, and
profile/activity surfaces
> - The branch had many small UI fixes that improve the operator loop
but do not need to ship with backend runtime migrations
> - These changes belong together as board workflow polish because they
affect scanning, navigation, issue context, comment state, and markdown
clarity
> - This pull request groups the UI-only slice so it can merge
independently from runtime/backend changes
> - The benefit is a clearer board experience with better issue context,
steadier optimistic updates, and more predictable keyboard navigation

## What Changed

- Improves issue properties, sub-issue actions, blocker chips, and issue
list/detail refresh behavior.
- Adds blocker context above the issue composer and stabilizes
queued/interrupted comment UI state.
- Improves markdown issue/GitHub link rendering and opens external
markdown links in a new tab.
- Adds inbox group keyboard navigation and fold/unfold support.
- Polishes activity/avatar/profile/settings/workspace presentation
details.

## Verification

- `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx
ui/src/components/IssueChatThread.test.tsx
ui/src/components/MarkdownBody.test.tsx ui/src/lib/inbox.test.ts
ui/src/lib/optimistic-issue-comments.test.ts`

## Risks

- Low to medium risk: changes are UI-focused but cover high-traffic
issue and inbox surfaces.
- This branch intentionally does not include the backend runtime changes
from the companion PR; where UI calls newer API filters, unsupported
servers should continue to fail visibly through existing API error
handling.
- Visual screenshots were not captured in this heartbeat; targeted
component/helper tests cover the changed behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent runtime, shell/git tool use
enabled. Exact hosted model build and context window are not exposed in
this Paperclip heartbeat environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-04-21 12:25:34 -05:00 committed by GitHub
parent 09d0678840
commit a26e1288b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1218 additions and 132 deletions

View file

@ -253,7 +253,7 @@ export function Agents() {
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
<span className="w-28 whitespace-nowrap text-right font-mono text-xs text-muted-foreground">
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
@ -356,7 +356,7 @@ function OrgTreeNode({
)}
{agent && (
<>
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
<span className="w-28 whitespace-nowrap text-right font-mono text-xs text-muted-foreground">
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">

View file

@ -535,7 +535,7 @@ export function ExecutionWorkspaceDetail() {
</p>
</div>
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Services and jobs</CardTitle>
<CardDescription>
@ -584,7 +584,7 @@ export function ExecutionWorkspaceDetail() {
{activeTab === "configuration" ? (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Workspace settings</CardTitle>
<CardDescription>
@ -594,7 +594,7 @@ export function ExecutionWorkspaceDetail() {
<Button
variant="destructive"
size="sm"
className="w-full sm:w-auto"
className="w-full rounded-none sm:w-auto"
onClick={() => setCloseDialogOpen(true)}
disabled={workspace.status === "archived"}
>
@ -804,7 +804,7 @@ export function ExecutionWorkspaceDetail() {
</CardContent>
</Card>
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Workspace context</CardTitle>
<CardDescription>Linked objects and relationships</CardDescription>
@ -850,7 +850,7 @@ export function ExecutionWorkspaceDetail() {
</CardContent>
</Card>
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Concrete location</CardTitle>
<CardDescription>Paths and refs</CardDescription>
@ -896,7 +896,7 @@ export function ExecutionWorkspaceDetail() {
</Card>
</div>
) : activeTab === "runtime_logs" ? (
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Runtime and cleanup logs</CardTitle>
<CardDescription>Recent operations</CardDescription>
@ -913,7 +913,7 @@ export function ExecutionWorkspaceDetail() {
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
<div className="space-y-3">
{workspaceOperationsQuery.data.map((operation) => (
<div key={operation.id} className="rounded-md border border-border/80 bg-background px-4 py-3">
<div key={operation.id} className="rounded-none border border-border/80 bg-background px-4 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>

View file

@ -1203,6 +1203,16 @@ export function Inbox() {
return next;
});
}, [selectedCompanyId]);
const setGroupCollapsed = useCallback((groupKey: string, collapsed: boolean) => {
setCollapsedGroupKeys((prev) => {
if (collapsed ? prev.has(groupKey) : !prev.has(groupKey)) return prev;
const next = new Set(prev);
if (collapsed) next.add(groupKey);
else next.delete(groupKey);
saveCollapsedInboxGroupKeys(selectedCompanyId, next);
return next;
});
}, [selectedCompanyId]);
const groupedSections = useMemo<InboxGroupedSection[]>(() => [
...buildGroupedInboxSections(filteredWorkItems, groupBy, inboxWorkspaceGrouping, { nestingEnabled }),
...buildGroupedInboxSections(
@ -1256,6 +1266,13 @@ export function Inbox() {
});
return map;
}, [flatNavItems]);
const groupFlatIndex = useMemo(() => {
const map = new Map<string, number>();
flatNavItems.forEach((entry, index) => {
if (entry.type === "group") map.set(entry.groupKey, index);
});
return map;
}, [flatNavItems]);
const agentName = (id: string | null) => {
if (!id) return null;
@ -1623,6 +1640,7 @@ export function Inbox() {
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
markNonIssueRead: handleMarkNonIssueRead,
markNonIssueUnread: markItemUnread,
setGroupCollapsed,
navigate,
});
kbActionsRef.current = {
@ -1633,6 +1651,7 @@ export function Inbox() {
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
markNonIssueRead: handleMarkNonIssueRead,
markNonIssueUnread: markItemUnread,
setGroupCollapsed,
navigate,
};
@ -1689,20 +1708,32 @@ export function Inbox() {
const entry = navItems[idx];
if (!entry) return {};
if (entry.type === "child") return { issue: entry.issue };
return { item: entry.item };
if (entry.type === "top") return { item: entry.item };
return {};
};
switch (e.key) {
case "j": {
case "j":
case "ArrowDown": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "next"));
break;
}
case "k": {
case "k":
case "ArrowUp": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "previous"));
break;
}
case "ArrowLeft":
case "ArrowRight": {
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
const entry = navItems[st.selectedIndex];
if (!entry || entry.type !== "group") return;
e.preventDefault();
act.setGroupCollapsed(entry.groupKey, e.key === "ArrowLeft");
break;
}
case "a":
case "y": {
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
@ -2237,13 +2268,20 @@ export function Inbox() {
);
}
if (group.label) {
const groupNavIdx = groupFlatIndex.get(group.key) ?? -1;
const isGroupSelected = groupNavIdx >= 0 && selectedIndex === groupNavIdx;
elements.push(
<div
key={`group-${group.key}`}
data-inbox-item
className={cn(
"px-3 sm:px-4",
groupIndex > 0 && "pt-2",
isGroupSelected && "bg-accent/50",
)}
onClick={() => {
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
}}
>
<IssueGroupHeader
label={group.label}

View file

@ -90,7 +90,8 @@ export function InstanceGeneralSettings() {
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
Configure instance-wide defaults that affect how operator-visible logs are displayed.
Configure instance-wide preferences including log display, keyboard shortcuts, backup
retention, and data sharing.
</p>
</div>
@ -175,9 +176,9 @@ export function InstanceGeneralSettings() {
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Backup retention</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure how long to keep automatic database backups at each tier. Daily backups
are kept in full, then thinned to one per week and one per month. Backups are
compressed with gzip.
Configure how long automatic database backups are retained. Backups run roughly
every hour and are compressed with gzip. Within the daily window all backups are
kept; beyond that, one backup per week and one per month are preserved.
</p>
</div>

View file

@ -23,6 +23,7 @@ import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUs
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import {
hasLegacyIssueDetailQuery,
createIssueDetailPath,
@ -42,6 +43,7 @@ import {
applyOptimisticIssueFieldUpdate,
applyOptimisticIssueFieldUpdateToCollection,
applyOptimisticIssueCommentUpdate,
applyLocalQueuedIssueCommentState,
createOptimisticIssueComment,
flattenIssueCommentPages,
getNextIssueCommentPageParam,
@ -54,7 +56,7 @@ import {
type IssueCommentReassignment,
type OptimisticIssueComment,
} from "../lib/optimistic-issue-comments";
import { removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { ApprovalCard } from "../components/ApprovalCard";
@ -504,7 +506,9 @@ type IssueDetailChatTabProps = {
projectId: string | null;
issueStatus: Issue["status"];
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
hasOlderComments: boolean;
commentsLoadingOlder: boolean;
onLoadOlderComments: () => void;
@ -542,7 +546,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
projectId,
issueStatus,
executionRunId,
blockedBy,
comments,
locallyQueuedCommentRunIds,
hasOlderComments,
commentsLoadingOlder,
onLoadOlderComments,
@ -645,6 +651,14 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
return comments.map((comment) => {
const meta = runMetaByCommentId.get(comment.id);
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
queuedTargetRunId: locallyQueuedCommentRunIds.get(comment.id) ?? null,
hasLiveRuns,
runningRunId: runningIssueRun?.id ?? null,
});
if (locallyQueuedComment !== nextComment) {
return locallyQueuedComment;
}
if (
isQueuedIssueComment({
comment: nextComment,
@ -662,7 +676,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
}
return nextComment;
});
}, [comments, resolvedActivity, resolvedLinkedRuns, runningIssueRun]);
}, [comments, hasLiveRuns, locallyQueuedCommentRunIds, resolvedActivity, resolvedLinkedRuns, runningIssueRun]);
const timelineEvents = useMemo(
() => extractIssueTimelineEvents(resolvedActivity),
[resolvedActivity],
@ -693,6 +707,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
timelineEvents={timelineEvents}
liveRuns={resolvedLiveRuns}
activeRun={resolvedActiveRun}
blockedBy={blockedBy ?? []}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@ -931,6 +946,7 @@ export function IssueDetail() {
const [galleryOpen, setGalleryOpen] = useState(false);
const [galleryIndex, setGalleryIndex] = useState(0);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState<Map<string, string>>(() => new Map());
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
@ -1013,6 +1029,11 @@ export function IssueDetail() {
});
const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun;
const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun;
useEffect(() => {
if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) {
setLocallyQueuedCommentRunIds(new Map());
}
}, [hasLiveRuns, locallyQueuedCommentRunIds.size]);
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
[issueId, location.state, location.search],
@ -1027,6 +1048,13 @@ export function IssueDetail() {
enabled: !!resolvedCompanyId && !!issue?.id,
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(issue?.id ?? "pending"),
});
const { data: companyLiveRuns } = useQuery({
queryKey: resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"],
queryFn: () => heartbeatsApi.liveRunsForCompany(resolvedCompanyId!),
enabled: !!resolvedCompanyId,
refetchInterval: 5000,
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(resolvedCompanyId ?? "pending"),
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@ -1113,6 +1141,7 @@ export function IssueDetail() {
() => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
[rawChildIssues],
);
const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]);
const issuePanelKey = useMemo(
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
[childIssues, issue],
@ -1393,6 +1422,7 @@ export function IssueDetail() {
return {
optimisticCommentId: optimisticComment?.clientId ?? null,
queuedCommentTargetRunId: queuedComment?.id ?? null,
previousIssue,
};
},
@ -1418,6 +1448,13 @@ export function IssueDetail() {
});
}
}
if (context?.queuedCommentTargetRunId) {
setLocallyQueuedCommentRunIds((current) => {
const next = new Map(current);
next.set(comment.id, context.queuedCommentTargetRunId!);
return next;
});
}
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!),
(current) => current ? {
@ -1503,6 +1540,7 @@ export function IssueDetail() {
return {
optimisticCommentId: optimisticComment?.clientId ?? null,
queuedCommentTargetRunId: queuedComment?.id ?? null,
previousIssue,
};
},
@ -1531,6 +1569,13 @@ export function IssueDetail() {
});
}
}
if (comment && context?.queuedCommentTargetRunId) {
setLocallyQueuedCommentRunIds((current) => {
const next = new Map(current);
next.set(comment.id, context.queuedCommentTargetRunId!);
return next;
});
}
if (comment) {
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!),
@ -1574,10 +1619,12 @@ export function IssueDetail() {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
const previousRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueId!));
const previousLiveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueId!));
const previousActiveRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueId!));
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const liveRunList = previousLiveRuns ?? [];
const cachedActiveRun = previousActiveRun ?? null;
const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList);
@ -1602,11 +1649,16 @@ export function IssueDetail() {
queryKeys.issues.activeRun(issueId!),
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
);
queryClient.setQueryData(
queryKeys.issues.detail(issueId!),
(current: Issue | undefined) => clearIssueExecutionRun(current, runId),
);
return {
previousRuns,
previousLiveRuns,
previousActiveRun,
previousIssue,
};
},
onSuccess: () => {
@ -1622,6 +1674,7 @@ export function IssueDetail() {
queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns);
queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns);
queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun);
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context?.previousIssue);
pushToast({
title: "Interrupt failed",
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
@ -1633,6 +1686,12 @@ export function IssueDetail() {
const cancelQueuedComment = useMutation({
mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.cancelComment(issueId!, commentId),
onSuccess: (comment) => {
setLocallyQueuedCommentRunIds((current) => {
if (!current.has(comment.id)) return current;
const next = new Map(current);
next.delete(comment.id);
return next;
});
removeCommentFromCache(comment.id);
restoreQueuedCommentDraft(comment.body);
invalidateIssueDetail();
@ -2481,6 +2540,7 @@ export function IssueDetail() {
isLoading={childIssuesLoading}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
projectId={issue.projectId ?? undefined}
viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`}
issueLinkState={resolvedIssueDetailState ?? location.state}
@ -2699,7 +2759,9 @@ export function IssueDetail() {
projectId={issue.projectId ?? null}
issueStatus={issue.status}
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
hasOlderComments={hasOlderComments}
commentsLoadingOlder={commentsLoadingOlder}
onLoadOlderComments={loadOlderComments}

View file

@ -7,12 +7,15 @@ import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
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";
const WORKSPACE_FILTER_ISSUE_LIMIT = 1000;
export function buildIssuesSearchUrl(currentHref: string, search: string): string | null {
const url = new URL(currentHref);
const currentSearch = url.searchParams.get("q") ?? "";
@ -36,6 +39,8 @@ export function Issues() {
const initialSearch = searchParams.get("q") ?? "";
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0);
const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined;
const handleSearchChange = useCallback((search: string) => {
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
if (!nextUrl) return;
@ -61,13 +66,7 @@ export function Issues() {
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const issueLinkState = useMemo(
() =>
@ -88,9 +87,16 @@ export function Issues() {
...queryKeys.issues.list(selectedCompanyId!),
"participant-agent",
participantAgentId ?? "__all__",
"workspace",
workspaceIdFilter ?? "__all__",
"with-routine-executions",
],
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, includeRoutineExecutions: true }),
queryFn: () => issuesApi.list(selectedCompanyId!, {
participantAgentId,
workspaceId: workspaceIdFilter,
includeRoutineExecutions: true,
...(workspaceIdFilter ? { limit: WORKSPACE_FILTER_ISSUE_LIMIT } : {}),
}),
enabled: !!selectedCompanyId,
});
@ -117,11 +123,12 @@ export function Issues() {
viewStateKey="paperclip:issues-view"
issueLinkState={issueLinkState}
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined}
initialSearch={initialSearch}
onSearchChange={handleSearchChange}
enableRoutineVisibilityFilter
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
searchFilters={participantAgentId ? { participantAgentId } : undefined}
searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined}
/>
);
}

View file

@ -245,7 +245,6 @@ describe("OrgChart mobile gestures", () => {
expect(navigateMock).toHaveBeenCalledWith("/agents/ceo");
});
it("pinch-zooms toward the touch center", async () => {
const { viewport, layer } = await renderOrgChart();

View file

@ -14,6 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
import {
formatCents,
formatDate,
formatNumber,
formatShortDate,
formatTokens,
issueUrl,
@ -59,10 +60,10 @@ function WindowColumn({ stats }: { stats: UserProfileWindowStats }) {
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-3">
<Metric value={String(stats.touchedIssues)} label="Touched" />
<Metric value={String(stats.completedIssues)} label="Completed" />
<Metric value={String(stats.commentCount)} label="Comments" />
<Metric value={String(stats.activityCount)} label="Actions" />
<Metric value={formatNumber(stats.touchedIssues)} label="Touched" />
<Metric value={formatNumber(stats.completedIssues)} label="Completed" />
<Metric value={formatNumber(stats.commentCount)} label="Comments" />
<Metric value={formatNumber(stats.activityCount)} label="Actions" />
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-1.5 pt-3 text-xs tabular-nums text-muted-foreground">
@ -71,9 +72,9 @@ function WindowColumn({ stats }: { stats: UserProfileWindowStats }) {
<span>Spend</span>
<span className="text-right text-foreground">{formatCents(stats.costCents)}</span>
<span>Created</span>
<span className="text-right text-foreground">{stats.createdIssues}</span>
<span className="text-right text-foreground">{formatNumber(stats.createdIssues)}</span>
<span>Open</span>
<span className="text-right text-foreground">{stats.assignedOpenIssues}</span>
<span className="text-right text-foreground">{formatNumber(stats.assignedOpenIssues)}</span>
</div>
</div>
);
@ -283,9 +284,9 @@ export function UserProfile() {
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<HeroStat label="All-time tokens" value={formatTokens(allTimeTokens)} hint={formatCents(allTime?.costCents ?? 0) + " spent"} />
<HeroStat label="Completed" value={String(allTime?.completedIssues ?? 0)} hint={allTime ? `${completionRate(allTime)} rate` : undefined} />
<HeroStat label="Open assigned" value={String(allTime?.assignedOpenIssues ?? 0)} hint={`${allTime?.createdIssues ?? 0} created`} />
<HeroStat label="7-day actions" value={String(last7?.activityCount ?? 0)} hint={`${last7?.commentCount ?? 0} comments`} />
<HeroStat label="Completed" value={formatNumber(allTime?.completedIssues ?? 0)} hint={allTime ? `${completionRate(allTime)} rate` : undefined} />
<HeroStat label="Open assigned" value={formatNumber(allTime?.assignedOpenIssues ?? 0)} hint={`${formatNumber(allTime?.createdIssues ?? 0)} created`} />
<HeroStat label="7-day actions" value={formatNumber(last7?.activityCount ?? 0)} hint={`${formatNumber(last7?.commentCount ?? 0)} comments`} />
</div>
</section>