mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
[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:
parent
09d0678840
commit
a26e1288b6
40 changed files with 1218 additions and 132 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue