Merge pull request #3205 from cryppadotta/pap-1239-ui-ux

feat(ui): improve issue detail and inbox workflows
This commit is contained in:
Dotta 2026-04-09 09:13:51 -05:00 committed by GitHub
commit 264eb34f24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 4688 additions and 1337 deletions

View file

@ -161,6 +161,8 @@ function boardRoutes() {
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
@ -349,6 +351,8 @@ export function App() {
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>

26
ui/src/api/issues.test.ts Normal file
View file

@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("./client", () => ({
api: mockApi,
}));
import { issuesApi } from "./issues";
describe("issuesApi.list", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.get.mockResolvedValue([]);
});
it("passes parentId through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { parentId: "issue-parent-1", limit: 25 });
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?parentId=issue-parent-1&limit=25",
);
});
});

View file

@ -24,6 +24,7 @@ export const issuesApi = {
filters?: {
status?: string;
projectId?: string;
parentId?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
@ -42,6 +43,7 @@ export const issuesApi = {
const params = new URLSearchParams();
if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.parentId) params.set("parentId", filters.parentId);
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
@ -80,7 +82,21 @@ export const issuesApi = {
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
}),
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
listComments: (
id: string,
filters?: {
after?: string;
order?: "asc" | "desc";
limit?: number;
},
) => {
const params = new URLSearchParams();
if (filters?.after) params.set("after", filters.after);
if (filters?.order) params.set("order", filters.order);
if (filters?.limit) params.set("limit", String(filters.limit));
const qs = params.toString();
return api.get<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
},
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams();

View file

@ -61,12 +61,26 @@ vi.mock("@/plugins/slots", () => ({
describe("CommentThread", () => {
let container: HTMLDivElement;
let writeTextMock: ReturnType<typeof vi.fn>;
let execCommandMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z"));
writeTextMock = vi.fn(async () => {});
execCommandMock = vi.fn(() => true);
Object.assign(navigator, {
clipboard: {
writeText: writeTextMock,
},
});
Object.defineProperty(window, "isSecureContext", {
value: true,
configurable: true,
});
document.execCommand = execCommandMock;
});
afterEach(() => {
@ -234,4 +248,59 @@ describe("CommentThread", () => {
root.unmount();
});
});
it("uses a larger copy control with feedback and a clipboard fallback", async () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-1",
body: "Hello from the comment body",
createdAt: new Date("2026-03-11T11:00:00.000Z"),
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
}]}
onAdd={async () => {}}
/>
</MemoryRouter>,
);
});
const copyButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.getAttribute("aria-label") === "Copy comment as markdown",
) as HTMLButtonElement | undefined;
expect(copyButton).toBeDefined();
expect(copyButton?.className).toContain("min-h-8");
expect(copyButton?.textContent).toContain("Copy");
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
await act(async () => {
copyButton?.click();
});
expect(writeTextMock).not.toHaveBeenCalled();
expect(execCommandMock).toHaveBeenCalledWith("copy");
expect(copyButton?.textContent).toContain("Copied");
act(() => {
vi.advanceTimersByTime(1500);
});
expect(copyButton?.textContent).toContain("Copy");
act(() => {
root.unmount();
});
});
});

View file

@ -210,21 +210,71 @@ function runStatusClass(status: string) {
}
}
async function copyTextWithFallback(text: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
try {
textarea.select();
const success = document.execCommand("copy");
if (!success) throw new Error("execCommand copy failed");
} finally {
document.body.removeChild(textarea);
}
}
function CopyMarkdownButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, []);
const label = status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : "Copy";
return (
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
title="Copy as markdown"
className={cn(
"inline-flex min-h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors",
status === "copied"
? "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-300"
: status === "failed"
? "bg-destructive/10 text-destructive"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
title={label}
aria-label="Copy comment as markdown"
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
void copyTextWithFallback(text)
.then(() => setStatus("copied"))
.catch(() => setStatus("failed"));
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setStatus("idle");
timeoutRef.current = null;
}, 1500);
}}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{status === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
<span className="sm:hidden">{label}</span>
<span className="sr-only" aria-live="polite">
{label}
</span>
</button>
);
}

View file

@ -1,12 +1,20 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, createRef, forwardRef, useImperativeHandle } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
const { markdownEditorFocusMock } = vi.hoisted(() => ({
markdownEditorFocusMock: vi.fn(),
}));
const { threadMessagesMock } = vi.hoisted(() => ({
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
}));
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ThreadPrimitive: {
@ -17,7 +25,7 @@ vi.mock("@assistant-ui/react", () => ({
<div data-testid="thread-viewport" className={className}>{children}</div>
),
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Messages: () => <div data-testid="thread-messages" />,
Messages: () => threadMessagesMock(),
},
MessagePrimitive: {
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@ -48,22 +56,34 @@ vi.mock("./MarkdownBody", () => ({
}));
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: ({
MarkdownEditor: forwardRef(({
value = "",
onChange,
placeholder,
className,
contentClassName,
}: {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
}) => (
<textarea
aria-label="Issue chat editor"
placeholder={placeholder}
value={value}
onChange={(event) => onChange?.(event.target.value)}
/>
),
className?: string;
contentClassName?: string;
}, ref) => {
useImperativeHandle(ref, () => ({
focus: markdownEditorFocusMock,
}));
return (
<textarea
aria-label="Issue chat editor"
data-class-name={className}
data-content-class-name={contentClassName}
placeholder={placeholder}
value={value}
onChange={(event) => onChange?.(event.target.value)}
/>
);
}),
}));
vi.mock("./InlineEntitySelector", () => ({
@ -100,11 +120,14 @@ describe("IssueChatThread", () => {
container = document.createElement("div");
document.body.appendChild(container);
localStorage.clear();
threadMessagesMock.mockImplementation(() => <div data-testid="thread-messages" />);
});
afterEach(() => {
container.remove();
vi.useRealTimers();
markdownEditorFocusMock.mockReset();
threadMessagesMock.mockReset();
});
it("drops the count heading and does not use an internal scrollbox", () => {
@ -172,6 +195,48 @@ describe("IssueChatThread", () => {
});
});
it("falls back to a safe transcript warning when assistant-ui throws during message rendering", () => {
const root = createRoot(container);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
threadMessagesMock.mockImplementation(() => {
throw new Error("tapClientLookup: Index 8 out of bounds (length: 8)");
});
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent summary",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
}]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Chat renderer hit an internal state error.");
expect(container.textContent).toContain("Agent summary");
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
act(() => {
root.unmount();
});
});
it("stores and restores the composer draft per issue key", () => {
vi.useFakeTimers();
const root = createRoot(container);
@ -240,6 +305,88 @@ describe("IssueChatThread", () => {
});
});
it("keeps the composer inline with bottom breathing room and a capped editor height", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
expect(composer).not.toBeNull();
expect(composer?.className).not.toContain("sticky");
expect(composer?.className).not.toContain("bottom-0");
expect(composer?.className).toContain("pb-[calc(env(safe-area-inset-bottom)+1.5rem)]");
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]");
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
act(() => {
root.unmount();
});
});
it("exposes a composer focus handle that forwards to the editor", () => {
const root = createRoot(container);
const composerRef = createRef<{ focus: () => void }>();
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
const requestAnimationFrameMock = vi
.spyOn(window, "requestAnimationFrame")
.mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
composerRef={composerRef}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
expect(composerRef.current).not.toBeNull();
expect(composer).not.toBeNull();
const scrollIntoViewMock = vi.fn();
composer!.scrollIntoView = scrollIntoViewMock;
act(() => {
composerRef.current?.focus();
});
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "end" });
expect(scrollByMock).toHaveBeenCalledWith({ top: 96, behavior: "smooth" });
expect(markdownEditorFocusMock).toHaveBeenCalledTimes(1);
scrollByMock.mockRestore();
requestAnimationFrameMock.mockRestore();
act(() => {
root.unmount();
});
});
it("folds chain-of-thought when the same message transitions from running to complete", () => {
expect(resolveAssistantMessageFoldedState({
messageId: "message-1",

View file

@ -8,7 +8,21 @@ import {
useMessage,
} from "@assistant-ui/react";
import type { ToolCallMessagePart } from "@assistant-ui/react";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import {
createContext,
Component,
forwardRef,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ChangeEvent,
type ErrorInfo,
type Ref,
type ReactNode,
} from "react";
import { Link, useLocation } from "@/lib/router";
import type {
Agent,
@ -65,7 +79,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
interface IssueChatMessageContext {
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
@ -80,6 +94,7 @@ interface IssueChatMessageContext {
) => Promise<void>;
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
@ -144,6 +159,24 @@ interface CommentReassignment {
assigneeUserId: string | null;
}
export interface IssueChatComposerHandle {
focus: () => void;
}
interface IssueChatComposerProps {
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
agentMap?: Map<string, Agent>;
composerDisabledReason?: string | null;
issueStatus?: string;
}
interface IssueChatThreadProps {
comments: IssueChatComment[];
feedbackVotes?: FeedbackVote[];
@ -184,9 +217,151 @@ interface IssueChatThreadProps {
includeSucceededRunsWithoutOutput?: boolean;
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void;
composerRef?: Ref<IssueChatComposerHandle>;
}
type IssueChatErrorBoundaryProps = {
resetKey: string;
messages: readonly import("@assistant-ui/react").ThreadMessage[];
emptyMessage: string;
variant: "full" | "embedded";
children: ReactNode;
};
type IssueChatErrorBoundaryState = {
hasError: boolean;
};
class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, IssueChatErrorBoundaryState> {
override state: IssueChatErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): IssueChatErrorBoundaryState {
return { hasError: true };
}
override componentDidCatch(error: unknown, info: ErrorInfo): void {
console.error("Issue chat renderer failed; falling back to safe transcript view", {
error,
info: info.componentStack,
});
}
override componentDidUpdate(prevProps: IssueChatErrorBoundaryProps): void {
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
this.setState({ hasError: false });
}
}
override render() {
if (this.state.hasError) {
return (
<IssueChatFallbackThread
messages={this.props.messages}
emptyMessage={this.props.emptyMessage}
variant={this.props.variant}
/>
);
}
return this.props.children;
}
}
function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessage) {
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
if (typeof custom?.["authorName"] === "string") return custom["authorName"];
if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"];
if (message.role === "assistant") return "Agent";
if (message.role === "user") return "You";
return "System";
}
function fallbackTextParts(message: import("@assistant-ui/react").ThreadMessage) {
const contentLines: string[] = [];
for (const part of message.content) {
if (part.type === "text" || part.type === "reasoning") {
if (part.text.trim().length > 0) contentLines.push(part.text);
continue;
}
if (part.type === "tool-call") {
const lines = [`Tool: ${part.toolName}`];
if (part.argsText?.trim()) lines.push(`Args:\n${part.argsText}`);
if (typeof part.result === "string" && part.result.trim()) lines.push(`Result:\n${part.result}`);
contentLines.push(lines.join("\n\n"));
}
}
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
if (contentLines.length === 0 && typeof custom?.["waitingText"] === "string" && custom["waitingText"].trim()) {
contentLines.push(custom["waitingText"]);
}
return contentLines;
}
function IssueChatFallbackThread({
messages,
emptyMessage,
variant,
}: {
messages: readonly import("@assistant-ui/react").ThreadMessage[];
emptyMessage: string;
variant: "full" | "embedded";
}) {
return (
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
<div className="rounded-xl border border-amber-300/60 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="font-medium">Chat renderer hit an internal state error.</p>
<p className="text-xs opacity-80">
Showing a safe fallback transcript instead of crashing the issues page.
</p>
</div>
</div>
</div>
{messages.length === 0 ? (
<div className={cn(
"text-center text-sm text-muted-foreground",
variant === "embedded"
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
)}>
{emptyMessage}
</div>
) : (
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
{messages.map((message) => {
const lines = fallbackTextParts(message);
return (
<div key={message.id} className="rounded-xl border border-border/60 bg-card/70 px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-sm">
<span className="font-medium text-foreground">{fallbackAuthorLabel(message)}</span>
{message.createdAt ? (
<span className="text-[11px] text-muted-foreground">
{commentDateLabel(message.createdAt)}
</span>
) : null}
</div>
<div className="space-y-2">
{lines.length > 0 ? lines.map((line, index) => (
<MarkdownBody key={`${message.id}:fallback:${index}`}>{line}</MarkdownBody>
)) : (
<p className="text-sm text-muted-foreground">No message content.</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
const DRAFT_DEBOUNCE_MS = 800;
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
function toIsoString(value: string | Date | null | undefined): string | null {
if (!value) return null;
@ -246,8 +421,9 @@ function commentDateLabel(date: Date | string | undefined): string {
}
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
const { onImageClick } = useContext(IssueChatCtx);
return (
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined}>
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined} onImageClick={onImageClick}>
{text}
</MarkdownBody>
);
@ -815,25 +991,26 @@ function IssueChatAssistantMessage() {
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
const isFoldable = !isRunning && hasCoT && !!chainOfThoughtLabel;
const isFoldable = !isRunning && !!chainOfThoughtLabel;
const [folded, setFolded] = useState(isFoldable);
const previousMessageIdRef = useRef<string | null>(message.id);
const previousIsFoldableRef = useRef(isFoldable);
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable });
useEffect(() => {
// Derive fold state synchronously during render (not in useEffect) so the
// browser never paints the un-folded intermediate state — prevents the
// visible "jump" when loading a page with already-folded work sections.
if (message.id !== prevFoldKey.messageId || isFoldable !== prevFoldKey.isFoldable) {
const nextFolded = resolveAssistantMessageFoldedState({
messageId: message.id,
currentFolded: folded,
isFoldable,
previousMessageId: previousMessageIdRef.current,
previousIsFoldable: previousIsFoldableRef.current,
previousMessageId: prevFoldKey.messageId,
previousIsFoldable: prevFoldKey.isFoldable,
});
previousMessageIdRef.current = message.id;
previousIsFoldableRef.current = isFoldable;
setPrevFoldKey({ messageId: message.id, isFoldable });
if (nextFolded !== folded) {
setFolded(nextFolded);
}
}, [folded, isFoldable, message.id]);
}
const handleVote = async (
vote: FeedbackVoteValue,
@ -896,8 +1073,15 @@ function IssueChatAssistantMessage() {
}}
/>
{message.content.length === 0 && waitingText ? (
<div className="rounded-sm bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
{waitingText}
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
{agentIcon ? (
<AgentIcon icon={agentIcon} className="h-4 w-4 shrink-0" />
) : (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="shimmer-text">{waitingText}</span>
</span>
</div>
) : null}
{notices.length > 0 ? (
@ -1350,7 +1534,7 @@ function IssueChatSystemMessage() {
return null;
}
function IssueChatComposer({
const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerProps>(function IssueChatComposer({
onImageUpload,
onAttachImage,
draftKey,
@ -1362,19 +1546,7 @@ function IssueChatComposer({
agentMap,
composerDisabledReason = null,
issueStatus,
}: {
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
agentMap?: Map<string, Agent>;
composerDisabledReason?: string | null;
issueStatus?: string;
}) {
}, forwardedRef) {
const api = useAui();
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
@ -1384,6 +1556,7 @@ function IssueChatComposer({
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const composerContainerRef = useRef<HTMLDivElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@ -1409,6 +1582,16 @@ function IssueChatComposer({
setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]);
useImperativeHandle(forwardedRef, () => ({
focus: () => {
composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
requestAnimationFrame(() => {
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
editorRef.current?.focus();
});
},
}), []);
async function handleSubmit() {
const trimmed = body.trim();
if (!trimmed || submitting) return;
@ -1477,7 +1660,11 @@ function IssueChatComposer({
}
return (
<div className="space-y-2">
<div
ref={composerContainerRef}
data-testid="issue-chat-composer"
className="space-y-3 pt-4 pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
>
<MarkdownEditor
ref={editorRef}
value={body}
@ -1486,10 +1673,11 @@ function IssueChatComposer({
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={onImageUpload}
contentClassName="min-h-[72px] text-sm"
bordered
contentClassName="min-h-[72px] max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
/>
<div className="mt-3 flex items-center justify-end gap-3">
<div className="flex flex-wrap items-center justify-end gap-3">
{(onImageUpload || onAttachImage) ? (
<div className="mr-auto flex items-center gap-3">
<input
@ -1566,7 +1754,7 @@ function IssueChatComposer({
</div>
</div>
);
}
});
export function IssueChatThread({
comments,
@ -1604,6 +1792,8 @@ export function IssueChatThread({
includeSucceededRunsWithoutOutput = false,
onInterruptQueued,
interruptingQueuedRunId = null,
onImageClick,
composerRef,
}: IssueChatThreadProps) {
const location = useLocation();
const hasScrolledRef = useRef(false);
@ -1731,6 +1921,7 @@ export function IssueChatThread({
onVote,
onInterruptQueued,
interruptingQueuedRunId,
onImageClick,
}),
[
feedbackVoteByTargetId,
@ -1741,6 +1932,7 @@ export function IssueChatThread({
onVote,
onInterruptQueued,
interruptingQueuedRunId,
onImageClick,
],
);
@ -1758,6 +1950,10 @@ export function IssueChatThread({
?? (variant === "embedded"
? "No run output yet."
: "This issue conversation is empty. Start with a message below.");
const errorBoundaryResetKey = useMemo(
() => messages.map((message) => `${message.id}:${message.role}:${message.content.length}:${message.status?.type ?? "none"}`).join("|"),
[messages],
);
return (
<AssistantRuntimeProvider runtime={runtime}>
@ -1775,25 +1971,33 @@ export function IssueChatThread({
</div>
) : null}
<ThreadPrimitive.Root className="">
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
<ThreadPrimitive.Empty>
<div className={cn(
"text-center text-sm text-muted-foreground",
variant === "embedded"
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
)}>
{resolvedEmptyMessage}
</div>
</ThreadPrimitive.Empty>
<ThreadPrimitive.Messages components={components} />
<div ref={bottomAnchorRef} />
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
<IssueChatErrorBoundary
resetKey={errorBoundaryResetKey}
messages={messages}
emptyMessage={resolvedEmptyMessage}
variant={variant}
>
<ThreadPrimitive.Root className="">
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
<ThreadPrimitive.Empty>
<div className={cn(
"text-center text-sm text-muted-foreground",
variant === "embedded"
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
)}>
{resolvedEmptyMessage}
</div>
</ThreadPrimitive.Empty>
<ThreadPrimitive.Messages components={components} />
<div ref={bottomAnchorRef} />
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
</IssueChatErrorBoundary>
{showComposer ? (
<IssueChatComposer
ref={composerRef}
onImageUpload={imageUploadHandler}
onAttachImage={onAttachImage}
draftKey={draftKey}

View file

@ -0,0 +1,343 @@
import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Columns3 } from "lucide-react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { InboxIssueColumn } from "../lib/inbox";
import { cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Identity } from "./Identity";
import { StatusIcon } from "./StatusIcon";
export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
const issueColumnLabels: Record<InboxIssueColumn, string> = {
status: "Status",
id: "ID",
assignee: "Assignee",
project: "Project",
workspace: "Workspace",
parent: "Parent issue",
labels: "Tags",
updated: "Last updated",
};
const issueColumnDescriptions: Record<InboxIssueColumn, string> = {
status: "Issue state chip on the left edge.",
id: "Ticket identifier like PAP-1009.",
assignee: "Assigned agent or board user.",
project: "Linked project pill with its color.",
workspace: "Execution or project workspace used for the issue.",
parent: "Parent issue identifier and title.",
labels: "Issue labels and tags.",
updated: "Latest visible activity time.",
};
export function issueActivityText(issue: Issue): string {
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
}
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
return columns
.map((column) => {
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
if (column === "project") return "minmax(6.5rem, 8.5rem)";
if (column === "workspace") return "minmax(9rem, 12rem)";
if (column === "parent") return "minmax(5rem, 7rem)";
if (column === "labels") return "minmax(8rem, 10rem)";
return "minmax(4rem, 5.5rem)";
})
.join(" ");
}
export function IssueColumnPicker({
availableColumns,
visibleColumnSet,
onToggleColumn,
onResetColumns,
title,
}: {
availableColumns: InboxIssueColumn[];
visibleColumnSet: ReadonlySet<InboxIssueColumn>;
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
onResetColumns: () => void;
title: string;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"
>
<Columns3 className="mr-1 h-3.5 w-3.5" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Desktop issue rows
</div>
<div className="text-sm font-medium text-foreground">
{title}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableColumns.map((column) => (
<DropdownMenuCheckboxItem
key={column}
checked={visibleColumnSet.has(column)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => onToggleColumn(column, checked === true)}
className="items-start rounded-lg px-3 py-2.5 pl-8"
>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
{issueColumnLabels[column]}
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{issueColumnDescriptions[column]}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={onResetColumns}
className="rounded-lg px-3 py-2 text-sm"
>
Reset defaults
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function InboxIssueMetaLeading({
issue,
isLive,
showStatus = true,
showIdentifier = true,
statusSlot,
}: {
issue: Issue;
isLive: boolean;
showStatus?: boolean;
showIdentifier?: boolean;
statusSlot?: ReactNode;
}) {
return (
<>
{showStatus ? (
<span className="hidden shrink-0 sm:inline-flex">
{statusSlot ?? <StatusIcon status={issue.status} />}
</span>
) : null}
{showIdentifier ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
) : null}
{isLive && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
"bg-blue-500/10",
)}
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
"bg-blue-500",
)}
/>
</span>
<span
className={cn(
"hidden text-[11px] font-medium sm:inline",
"text-blue-600 dark:text-blue-400",
)}
>
Live
</span>
</span>
)}
</>
);
}
export function InboxIssueTrailingColumns({
issue,
columns,
projectName,
projectColor,
workspaceName,
assigneeName,
currentUserId,
parentIdentifier,
parentTitle,
assigneeContent,
}: {
issue: Issue;
columns: InboxIssueColumn[];
projectName: string | null;
projectColor: string | null;
workspaceName: string | null;
assigneeName: string | null;
currentUserId: string | null;
parentIdentifier: string | null;
parentTitle: string | null;
assigneeContent?: ReactNode;
}) {
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
return (
<span
className="grid items-center gap-2"
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
>
{columns.map((column) => {
if (column === "assignee") {
if (assigneeContent) {
return <span key={column} className="min-w-0">{assigneeContent}</span>;
}
if (issue.assigneeAgentId) {
return (
<span key={column} className="min-w-0 text-xs text-foreground">
<Identity
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
size="sm"
className="min-w-0"
/>
</span>
);
}
if (issue.assigneeUserId) {
return (
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
{userLabel}
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
Unassigned
</span>
);
}
if (column === "project") {
if (projectName) {
const accentColor = projectColor ?? "#64748b";
return (
<span
key={column}
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span className="truncate">{projectName}</span>
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
No project
</span>
);
}
if (column === "labels") {
if ((issue.labels ?? []).length > 0) {
return (
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
{(issue.labels ?? []).slice(0, 2).map((label) => (
<span
key={label.id}
className="inline-flex min-w-0 max-w-full items-center font-medium"
style={{
color: pickTextColorForPillBg(label.color, 0.12),
}}
>
<span className="truncate">{label.name}</span>
</span>
))}
{(issue.labels ?? []).length > 2 ? (
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
+{(issue.labels ?? []).length - 2}
</span>
) : null}
</span>
);
}
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
if (column === "workspace") {
if (!workspaceName) {
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
{workspaceName}
</span>
);
}
if (column === "parent") {
if (!issue.parentId) {
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
{parentIdentifier ? (
<span className="font-mono">{parentIdentifier}</span>
) : (
<span className="italic">Sub-issue</span>
)}
</span>
);
}
if (column === "updated") {
return (
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
{activityText}
</span>
);
}
return null;
})}
</span>
);
}

View file

@ -3,6 +3,7 @@
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -143,6 +144,30 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
};
}
function createExecutionPolicy(overrides: Partial<IssueExecutionPolicy> = {}): IssueExecutionPolicy {
return {
mode: "normal",
commentRequired: true,
stages: [],
...overrides,
};
}
function createExecutionState(overrides: Partial<IssueExecutionState> = {}): IssueExecutionState {
return {
status: "changes_requested",
currentStageId: "stage-1",
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: "agent-1", userId: null },
returnAssignee: { type: "agent", agentId: "agent-2", userId: null },
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: "changes_requested",
...overrides,
};
}
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
const queryClient = new QueryClient({
defaultOptions: {
@ -201,4 +226,119 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
}),
childIssues: [],
onUpdate,
});
await flush();
const runReviewButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Run review now"));
expect(runReviewButton).not.toBeUndefined();
await act(async () => {
runReviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ status: "in_review" });
act(() => root.unmount());
});
it("shows a run approval action when approval is the next runnable stage", async () => {
const root = renderProperties(container, {
issue: createIssue({
executionPolicy: createExecutionPolicy({
stages: [
{
id: "approval-stage",
type: "approval",
approvalsNeeded: 1,
participants: [{ id: "participant-2", type: "user", agentId: null, userId: "user-1" }],
},
],
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Run approval now");
expect(container.textContent).not.toContain("Run review now");
act(() => root.unmount());
});
it("keeps the run review action available after changes are requested", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "in_progress",
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
executionState: createExecutionState(),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).toContain("Run review now");
act(() => root.unmount());
});
it("hides the run action while an execution stage is already pending", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "in_review",
executionPolicy: createExecutionPolicy({
stages: [
{
id: "review-stage",
type: "review",
approvalsNeeded: 1,
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
},
],
}),
executionState: createExecutionState({
status: "pending",
currentStageType: "review",
lastDecisionOutcome: null,
}),
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Run review now");
expect(container.textContent).not.toContain("Run approval now");
act(() => root.unmount());
});
});

View file

@ -309,6 +309,26 @@ export function IssueProperties({
const approverTrigger = approverValues.length > 0
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
: <span className="text-sm text-muted-foreground">None</span>;
const nextRunnableExecutionStage = (() => {
if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) {
return issue.executionState.currentStageType;
}
if (issue.executionState) return null;
if (reviewerValues.length > 0) return "review";
if (approverValues.length > 0) return "approval";
return null;
})();
const runExecutionButton = (stageType: "review" | "approval") => (
<PropertyRow label="">
<button
type="button"
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={() => onUpdate({ status: "in_review" })}
>
{stageType === "review" ? "Run review now" : "Run approval now"}
</button>
</PropertyRow>
);
const currentExecutionLabel = (() => {
if (!issue.executionState?.currentStageType) return null;
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
@ -846,15 +866,13 @@ export function IssueProperties({
</Link>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
) : null}
</PropertyRow>
<PropertyRow label="Sub-issues">
<div className="flex flex-wrap items-center gap-1.5">
{childIssues.length > 0 ? (
childIssues.map((child) => (
{childIssues.length > 0
? childIssues.map((child) => (
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
@ -863,9 +881,7 @@ export function IssueProperties({
{child.identifier ?? child.title}
</Link>
))
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
: null}
{onAddSubIssue ? (
<button
type="button"
@ -896,6 +912,7 @@ export function IssueProperties({
() => updateExecutionPolicy([], approverValues),
)}
</PropertyPicker>
{nextRunnableExecutionStage === "review" && reviewerValues.length > 0 ? runExecutionButton("review") : null}
<PropertyPicker
inline={inline}
@ -914,6 +931,7 @@ export function IssueProperties({
() => updateExecutionPolicy(reviewerValues, []),
)}
</PropertyPicker>
{nextRunnableExecutionStage === "approval" && approverValues.length > 0 ? runExecutionButton("approval") : null}
{currentExecutionLabel && (
<PropertyRow label="Execution">

View file

@ -0,0 +1,169 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, Project } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Issue workspace",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-04-08T00:00:00.000Z"),
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
...overrides,
};
}
function createProject(): Project {
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project 1",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
env: null,
pauseReason: null,
pausedAt: null,
archivedAt: null,
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
},
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-1",
effectiveLocalFolder: "/tmp/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
createdAt: new Date("2026-04-08T00:00:00.000Z"),
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
};
}
function renderCard(container: HTMLDivElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueWorkspaceCard issue={createIssue()} project={createProject()} onUpdate={() => {}} />
</QueryClientProvider>,
);
});
return root;
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("IssueWorkspaceCard", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockExecutionWorkspacesApi.list.mockReset();
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders a stable skeleton while workspace settings are still loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = renderCard(container);
await flush();
expect(container.querySelector('[data-testid="issue-workspace-card-skeleton"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
});
});

View file

@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectWorkspaceUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
/* -------------------------------------------------------------------------- */
@ -156,6 +157,25 @@ function statusBadge(status: string) {
);
}
function IssueWorkspaceCardSkeleton() {
return (
<div className="rounded-lg border border-border p-3 space-y-3" data-testid="issue-workspace-card-skeleton">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-6 w-14" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-full" />
</div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Main component */
/* -------------------------------------------------------------------------- */
@ -195,14 +215,15 @@ export function IssueWorkspaceCard({
const companyId = issue.companyId ?? selectedCompanyId;
const [editing, setEditing] = useState(initialEditing);
const { data: experimentalSettings } = useQuery({
const { data: experimentalSettings, isLoading: experimentalSettingsLoading } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const projectWorkspacePolicyEnabled = Boolean(project?.executionWorkspacePolicy?.enabled);
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
&& Boolean(project?.executionWorkspacePolicy?.enabled);
&& projectWorkspacePolicyEnabled;
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
@ -314,6 +335,10 @@ export function IssueWorkspaceCard({
setEditing(false);
}, [currentSelection, issue.executionWorkspaceId]);
if (project && projectWorkspacePolicyEnabled && experimentalSettingsLoading) {
return <IssueWorkspaceCardSkeleton />;
}
if (!policyEnabled || !project) return null;
const showEditingControls = livePreview || editing;

View file

@ -25,6 +25,14 @@ const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => companyState,
}));
@ -41,8 +49,30 @@ vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("./IssueRow", () => ({
IssueRow: ({ issue }: { issue: Issue }) => <div data-testid="issue-row">{issue.title}</div>,
IssueRow: ({
issue,
desktopMetaLeading,
desktopTrailing,
}: {
issue: Issue;
desktopMetaLeading?: ReactNode;
desktopTrailing?: ReactNode;
}) => (
<div data-testid="issue-row">
<span>{issue.title}</span>
{desktopMetaLeading}
{desktopTrailing}
</div>
),
}));
vi.mock("./KanbanBoard", () => ({
@ -90,6 +120,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: null,
isUnreadForMe: false,
...overrides,
};
@ -148,11 +179,18 @@ describe("IssuesList", () => {
mockIssuesApi.list.mockReset();
mockIssuesApi.listLabels.mockReset();
mockAuthApi.getSession.mockReset();
mockExecutionWorkspacesApi.list.mockReset();
mockInstanceSettingsApi.getExperimental.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
localStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
container.remove();
});
@ -184,4 +222,89 @@ describe("IssuesList", () => {
root.unmount();
});
});
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
vi.useFakeTimers();
const onSearchChange = vi.fn();
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
const { root } = renderWithQueryClient(
<IssuesList
issues={[localIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onSearchChange={onSearchChange}
onUpdateIssue={() => undefined}
/>,
container,
);
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
expect(valueSetter).toBeTypeOf("function");
act(() => {
if (!input || !valueSetter) return;
valueSetter.call(input, "a");
input.dispatchEvent(new Event("input", { bubbles: true }));
valueSetter.call(input, "ab");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(onSearchChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(149);
});
expect(onSearchChange).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
});
expect(onSearchChange).toHaveBeenCalledTimes(1);
expect(onSearchChange).toHaveBeenCalledWith("ab");
act(() => {
root.unmount();
});
});
it("reuses the inbox issue column controls and persisted column visibility", async () => {
localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "assignee"]));
const assignedIssue = createIssue({
id: "issue-assigned",
identifier: "PAP-9",
title: "Assigned issue",
assigneeAgentId: "agent-1",
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[assignedIssue]}
agents={[{ id: "agent-1", name: "Agent One" }]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.textContent).toContain("Columns");
expect(container.textContent).toContain("PAP-9");
expect(container.textContent).toContain("Agent One");
expect(container.textContent).not.toContain("Updated");
});
act(() => {
root.unmount();
});
});
});

View file

@ -1,15 +1,31 @@
import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { groupBy } from "../lib/groupBy";
import { formatDate, cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
getAvailableInboxIssueColumns,
loadInboxIssueColumns,
normalizeInboxIssueColumns,
resolveIssueWorkspaceName,
saveInboxIssueColumns,
type InboxIssueColumn,
} from "../lib/inbox";
import { cn } from "../lib/utils";
import {
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
IssueColumnPicker,
issueActivityText,
issueTrailingColumns,
} from "./IssueColumns";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { EmptyState } from "./EmptyState";
@ -24,12 +40,13 @@ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/component
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
import { KanbanBoard } from "./KanbanBoard";
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import type { Issue } from "@paperclipai/shared";
import type { Issue, Project } from "@paperclipai/shared";
/* ── Helpers ── */
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
const priorityOrder = ["critical", "high", "medium", "low"];
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
@ -45,7 +62,7 @@ export type IssueViewState = {
projects: string[];
sortField: "status" | "priority" | "title" | "created" | "updated";
sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "none";
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
viewMode: "list" | "board";
collapsedGroups: string[];
collapsedParents: string[];
@ -152,10 +169,7 @@ interface Agent {
name: string;
}
interface ProjectOption {
id: string;
name: string;
}
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
interface IssuesListProps {
issues: Issue[];
@ -176,6 +190,50 @@ interface IssuesListProps {
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
function IssueSearchInput({
value,
onDebouncedChange,
}: {
value: string;
onDebouncedChange?: (search: string) => void;
}) {
const [draftValue, setDraftValue] = useState(value);
const lastCommittedValueRef = useRef(value);
useEffect(() => {
setDraftValue(value);
lastCommittedValueRef.current = value;
}, [value]);
useEffect(() => {
if (!onDebouncedChange || draftValue === lastCommittedValueRef.current) return;
const timeoutId = window.setTimeout(() => {
lastCommittedValueRef.current = draftValue;
startTransition(() => {
onDebouncedChange(draftValue);
});
}, ISSUE_SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timeoutId);
}, [draftValue, onDebouncedChange]);
return (
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={draftValue}
onChange={(e) => {
setDraftValue(e.target.value);
}}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
);
}
export function IssuesList({
issues,
isLoading,
@ -198,7 +256,13 @@ export function IssuesList({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
// Scope the storage key per company so folding/view state is independent across companies.
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
@ -212,6 +276,7 @@ export function IssuesList({
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const deferredIssueSearch = useDeferredValue(issueSearch);
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
@ -259,12 +324,103 @@ export function IssuesList({
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
placeholderData: (previousData) => previousData,
});
const { data: executionWorkspaces = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.executionWorkspaces.list(selectedCompanyId)
: ["execution-workspaces", "__disabled__"],
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
});
const agentName = useCallback((id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
}, [agents]);
const projectById = useMemo(() => {
const map = new Map<string, { name: string; color: string | null }>();
for (const project of projects ?? []) {
map.set(project.id, { name: project.name, color: project.color ?? null });
}
return map;
}, [projects]);
const projectWorkspaceById = useMemo(() => {
const map = new Map<string, { name: string }>();
for (const project of projects ?? []) {
for (const workspace of project.workspaces ?? []) {
map.set(workspace.id, { name: workspace.name || project.name });
}
}
return map;
}, [projects]);
const defaultProjectWorkspaceIdByProjectId = useMemo(() => {
const map = new Map<string, string>();
for (const project of projects ?? []) {
const defaultWorkspaceId =
project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.primaryWorkspace?.id
?? null;
if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId);
}
return map;
}, [projects]);
const executionWorkspaceById = useMemo(() => {
const map = new Map<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>();
for (const workspace of executionWorkspaces) {
map.set(workspace.id, {
name: workspace.name,
mode: workspace.mode,
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
});
}
return map;
}, [executionWorkspaces]);
const workspaceNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const [workspaceId, workspace] of projectWorkspaceById) {
map.set(workspaceId, workspace.name);
}
for (const [workspaceId, workspace] of executionWorkspaceById) {
map.set(workspaceId, workspace.name);
}
return map;
}, [executionWorkspaceById, projectWorkspaceById]);
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
const availableIssueColumns = useMemo(
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
[isolatedWorkspacesEnabled],
);
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
const visibleTrailingIssueColumns = useMemo(
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
[availableIssueColumnSet, visibleIssueColumnSet],
);
const issueById = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues) {
map.set(issue.id, issue);
}
return map;
}, [issues]);
const issueTitleMap = useMemo(() => {
const map = new Map<string, string>();
for (const issue of issues) {
map.set(issue.id, issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title);
}
return map;
}, [issues]);
const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
@ -295,6 +451,36 @@ export function IssuesList({
.filter((p) => groups[p]?.length)
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
}
if (viewState.groupBy === "workspace") {
const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace");
return Object.keys(groups)
.sort((a, b) => {
// Groups with items first, "no workspace" last
if (a === "__no_workspace") return 1;
if (b === "__no_workspace") return -1;
return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0);
})
.map((key) => ({
key,
label: key === "__no_workspace" ? "No Workspace" : (workspaceNameMap.get(key) ?? key.slice(0, 8)),
items: groups[key]!,
}));
}
if (viewState.groupBy === "parent") {
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
return Object.keys(groups)
.sort((a, b) => {
// Groups with items first, "no parent" last
if (a === "__no_parent") return 1;
if (b === "__no_parent") return -1;
return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0);
})
.map((key) => ({
key,
label: key === "__no_parent" ? "No Parent" : (issueTitleMap.get(key) ?? key.slice(0, 8)),
items: groups[key]!,
}));
}
// assignee
const groups = groupBy(
filtered,
@ -310,7 +496,7 @@ export function IssuesList({
: (agentName(key) ?? key.slice(0, 8)),
items: groups[key]!,
}));
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
const newIssueDefaults = useCallback((groupKey?: string) => {
const defaults: Record<string, string> = {};
@ -322,10 +508,27 @@ export function IssuesList({
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
else defaults.assigneeAgentId = groupKey;
}
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
defaults.parentId = groupKey;
}
}
return defaults;
}, [projectId, viewState.groupBy]);
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
const normalized = normalizeInboxIssueColumns(next);
setVisibleIssueColumns(normalized);
saveInboxIssueColumns(normalized);
}, []);
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
if (enabled) {
setIssueColumns([...visibleIssueColumns, column]);
return;
}
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
}, [setIssueColumns, visibleIssueColumns]);
const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
setAssigneePickerIssueId(null);
@ -342,19 +545,13 @@ export function IssuesList({
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">New Issue</span>
</Button>
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={issueSearch}
onChange={(e) => {
setIssueSearch(e.target.value);
onSearchChange?.(e.target.value);
}}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
<IssueSearchInput
value={issueSearch}
onDebouncedChange={(nextSearch) => {
setIssueSearch(nextSearch);
onSearchChange?.(nextSearch);
}}
/>
</div>
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
@ -376,6 +573,14 @@ export function IssuesList({
</button>
</div>
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which issue columns stay visible"
/>
{/* Filter */}
<Popover>
<PopoverTrigger asChild>
@ -605,6 +810,8 @@ export function IssuesList({
["status", "Status"],
["priority", "Priority"],
["assignee", "Assignee"],
["workspace", "Workspace"],
["parent", "Parent Issue"],
["none", "None"],
] as const).map(([value, label]) => (
<button
@ -684,6 +891,8 @@ export function IssuesList({
const hasChildren = children.length > 0;
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
const isExpanded = !viewState.collapsedParents.includes(issue.id);
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
e.preventDefault();
e.stopPropagation();
@ -728,154 +937,139 @@ export function IssuesList({
) : (
<span className="hidden w-3.5 shrink-0 sm:block" />
)}
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
<InboxIssueMetaLeading
issue={issue}
isLive={liveIssueIds?.has(issue.id) === true}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
statusSlot={(
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
)}
/>
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
mobileMeta={issueActivityText(issue).toLowerCase()}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
visibleTrailingIssueColumns.length > 0 ? (
<InboxIssueTrailingColumns
issue={issue}
columns={visibleTrailingIssueColumns}
projectName={issueProject?.name ?? null}
projectColor={issueProject?.color ?? null}
workspaceName={resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(issue.assigneeAgentId)}
currentUserId={currentUserId}
parentIdentifier={parentIssue?.identifier ?? null}
parentTitle={parentIssue?.title ?? null}
assigneeContent={(
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<PopoverTrigger asChild>
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
assignIssue(issue.id, null, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
No assignee
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
/>
) : undefined
)}
trailingMeta={formatDate(issue.createdAt)}
/>
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
</div>

View file

@ -0,0 +1,101 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface ShortcutEntry {
keys: string[];
label: string;
}
interface ShortcutSection {
title: string;
shortcuts: ShortcutEntry[];
}
const sections: ShortcutSection[] = [
{
title: "Inbox",
shortcuts: [
{ keys: ["j"], label: "Move down" },
{ keys: ["k"], label: "Move up" },
{ keys: ["Enter"], label: "Open selected item" },
{ keys: ["a"], label: "Archive item" },
{ keys: ["y"], label: "Archive item" },
{ keys: ["r"], label: "Mark as read" },
{ keys: ["U"], label: "Mark as unread" },
],
},
{
title: "Issue detail",
shortcuts: [
{ keys: ["y"], label: "Quick-archive back to inbox" },
{ keys: ["g", "i"], label: "Go to inbox" },
{ keys: ["g", "c"], label: "Focus comment composer" },
],
},
{
title: "Global",
shortcuts: [
{ keys: ["c"], label: "New issue" },
{ keys: ["["], label: "Toggle sidebar" },
{ keys: ["]"], label: "Toggle panel" },
{ keys: ["?"], label: "Show keyboard shortcuts" },
],
},
];
function KeyCap({ children }: { children: string }) {
return (
<kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs font-medium text-foreground shadow-[0_1px_0_1px_hsl(var(--border))]">
{children}
</kbd>
);
}
export function KeyboardShortcutsCheatsheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden" showCloseButton={false}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="text-base">Keyboard shortcuts</DialogTitle>
</DialogHeader>
<div className="divide-y divide-border border-t border-border">
{sections.map((section) => (
<div key={section.title} className="px-5 py-3">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h3>
<div className="space-y-1.5">
{section.shortcuts.map((shortcut) => (
<div
key={shortcut.label + shortcut.keys.join()}
className="flex items-center justify-between gap-4"
>
<span className="text-sm text-foreground/90">{shortcut.label}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, i) => (
<span key={key} className="flex items-center gap-1">
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
<KeyCap>{key}</KeyCap>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="border-t border-border px-5 py-3">
<p className="text-xs text-muted-foreground">
Press <KeyCap>Esc</KeyCap> to close &middot; Shortcuts are disabled in text fields
</p>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -12,6 +12,7 @@ import { NewIssueDialog } from "./NewIssueDialog";
import { NewProjectDialog } from "./NewProjectDialog";
import { NewGoalDialog } from "./NewGoalDialog";
import { NewAgentDialog } from "./NewAgentDialog";
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
@ -32,6 +33,7 @@ import {
normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings";
import { queryKeys } from "../lib/queryKeys";
import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
@ -69,6 +71,7 @@ export function Layout() {
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@ -151,6 +154,7 @@ export function Layout() {
onNewIssue: () => openNewIssue(),
onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel,
onShowShortcuts: () => setShortcutsOpen(true),
});
useEffect(() => {
@ -265,6 +269,12 @@ export function Layout() {
}
}, [location.hash, location.pathname, location.search]);
useEffect(() => {
if (typeof document === "undefined") return;
const mainContent = document.getElementById("main-content");
return scheduleMainContentFocus(mainContent);
}, [location.pathname]);
return (
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
@ -420,7 +430,7 @@ export function Layout() {
id="main-content"
tabIndex={-1}
className={cn(
"flex-1 p-4 md:p-6",
"flex-1 p-4 outline-none md:p-6",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
)}
>
@ -443,6 +453,7 @@ export function Layout() {
<NewProjectDialog />
<NewGoalDialog />
<NewAgentDialog />
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
<ToastViewport />
</div>
</GeneralSettingsProvider>

View file

@ -11,6 +11,8 @@ interface MarkdownBodyProps {
style?: React.CSSProperties;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
/** Called when a user clicks an inline image */
onImageClick?: (src: string) => void;
}
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
@ -92,7 +94,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
);
}
export function MarkdownBody({ children, className, style, resolveImageSrc }: MarkdownBodyProps) {
export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
const { theme } = useTheme();
const components: Components = {
pre: ({ node: _node, children: preChildren, ...preProps }) => {
@ -132,10 +134,19 @@ export function MarkdownBody({ children, className, style, resolveImageSrc }: Ma
);
},
};
if (resolveImageSrc) {
if (resolveImageSrc || onImageClick) {
components.img = ({ node: _node, src, alt, ...imgProps }) => {
const resolved = src ? resolveImageSrc(src) : null;
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
const finalSrc = resolved ?? src;
return (
<img
{...imgProps}
src={finalSrc}
alt={alt ?? ""}
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
/>
);
};
}

View file

@ -364,6 +364,19 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return map;
}, [mentions]);
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
ref.current = instance;
if (!instance) {
return;
}
if (valueRef.current !== latestValueRef.current) {
// Re-apply the latest controlled value once MDXEditor exposes its imperative API.
echoIgnoreMarkdownRef.current = valueRef.current;
instance.setMarkdown(valueRef.current);
latestValueRef.current = valueRef.current;
}
}, []);
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
if (!mentionState) return [];
const q = mentionState.query.trim().toLowerCase();
@ -379,16 +392,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
}, [mentionState, mentions, slashCommands]);
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
ref.current = instance;
if (instance) {
const v = valueRef.current;
echoIgnoreMarkdownRef.current = v;
instance.setMarkdown(v);
latestValueRef.current = v;
}
}, []);
useImperativeHandle(forwardedRef, () => ({
focus: () => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });

View file

@ -1,7 +1,18 @@
import { useCallback, useState } from "react";
import { getWorktreeUiBranding } from "../lib/worktree-branding";
export function WorktreeBanner() {
const branding = getWorktreeUiBranding();
const [copied, setCopied] = useState(false);
const handleCopyName = useCallback(() => {
if (!branding) return;
navigator.clipboard.writeText(branding.name).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}, [branding]);
if (!branding) return null;
return (
@ -18,7 +29,14 @@ export function WorktreeBanner() {
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
<span className="shrink-0 opacity-70">Worktree</span>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
<span className="truncate font-semibold tracking-[0.12em]">{branding.name}</span>
<button
type="button"
onClick={handleCopyName}
title="Click to copy worktree name"
className="truncate font-semibold tracking-[0.12em] cursor-pointer hover:opacity-80 transition-opacity bg-transparent border-none p-0 text-current uppercase text-[11px]"
>
{copied ? "Copied!" : branding.name}
</button>
</div>
</div>
);

View file

@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
const { useQueryMock, logMock } = vi.hoisted(() => ({
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: useQueryMock,
}));
vi.mock("../../api/instanceSettings", () => ({
instanceSettingsApi: {
getGeneral: vi.fn(),
},
}));
vi.mock("../../api/heartbeats", () => ({
heartbeatsApi: {
log: logMock,
},
}));
vi.mock("../../adapters", () => ({
buildTranscript: (chunks: unknown[]) => chunks,
getUIAdapter: () => null,
onAdapterChange: () => () => {},
}));
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: FakeWebSocket[] = [];
readonly url: string;
readyState = FakeWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
closeCalls: Array<{ code?: number; reason?: string }> = [];
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
close(code?: number, reason?: string) {
this.closeCalls.push({ code, reason });
this.readyState = FakeWebSocket.CLOSING;
}
triggerOpen() {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.(new Event("open"));
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("useLiveRunTranscripts", () => {
const OriginalWebSocket = globalThis.WebSocket;
beforeEach(() => {
FakeWebSocket.instances = [];
useQueryMock.mockClear();
logMock.mockClear();
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
});
afterEach(() => {
globalThis.WebSocket = OriginalWebSocket;
});
it("waits for a connecting socket to open before closing it during cleanup", async () => {
function Harness() {
useLiveRunTranscripts({
companyId: "company-1",
runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }],
});
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
});
expect(FakeWebSocket.instances).toHaveLength(1);
const socket = FakeWebSocket.instances[0];
expect(socket.closeCalls).toHaveLength(0);
act(() => {
root.unmount();
});
expect(socket.closeCalls).toHaveLength(0);
act(() => {
socket.triggerOpen();
});
expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]);
container.remove();
});
});

View file

@ -281,7 +281,16 @@ export function useLiveRunTranscripts({
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close(1000, "live_run_transcripts_unmount");
if (socket.readyState === WebSocket.CONNECTING) {
// Defer the close until the handshake completes so the browser
// does not emit a noisy "closed before the connection is established"
// warning during rapid run teardown.
socket.onopen = () => {
socket?.close(1000, "live_run_transcripts_unmount");
};
} else if (socket.readyState === WebSocket.OPEN) {
socket.close(1000, "live_run_transcripts_unmount");
}
}
};
}, [activeRunIds, companyId, runById]);

View file

@ -38,20 +38,24 @@ const buttonVariants = cva(
}
)
function Button({
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}
>(function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
}, ref) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
ref={ref}
data-slot="button"
data-variant={variant}
data-size={size}
@ -59,6 +63,8 @@ function Button({
{...props}
/>
)
}
})
Button.displayName = "Button"
export { Button, buttonVariants }

View file

@ -5,7 +5,7 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
import { queryKeys } from "../lib/queryKeys";
describe("LiveUpdatesProvider issue invalidation", () => {
it("refreshes touched inbox queries for issue activity", () => {
it("refreshes touched inbox queries and only the changed issue data for issue updates", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
@ -20,6 +20,7 @@ describe("LiveUpdatesProvider issue invalidation", () => {
{
entityType: "issue",
entityId: "issue-1",
action: "issue.updated",
details: null,
},
);
@ -33,6 +34,58 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.detail("issue-1"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.activity("issue-1"),
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.comments("issue-1"),
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.runs("issue-1"),
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.documents("issue-1"),
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.attachments("issue-1"),
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.approvals("issue-1"),
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.liveRuns("issue-1"),
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.activeRun("issue-1"),
});
});
it("still refreshes comments when a comment activity event arrives", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "issue",
entityId: "issue-1",
action: "issue.comment_added",
details: null,
},
);
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.comments("issue-1"),
});
});
});

View file

@ -487,6 +487,7 @@ function invalidateActivityQueries(
const entityType = readString(payload.entityType);
const entityId = readString(payload.entityId);
const action = readString(payload.action);
if (entityType === "issue") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
@ -498,14 +499,10 @@ function invalidateActivityQueries(
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
for (const ref of issueRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
if (action === "issue.comment_added") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
}
}
}
return;

View file

@ -0,0 +1,58 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function TestHarness({
onNewIssue,
}: {
onNewIssue: () => void;
}) {
useKeyboardShortcuts({
enabled: true,
onNewIssue,
});
return <div>keyboard shortcuts test</div>;
}
describe("useKeyboardShortcuts", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("ignores events already claimed by another handler", () => {
const root = createRoot(container);
const onNewIssue = vi.fn();
act(() => {
root.render(<TestHarness onNewIssue={onNewIssue} />);
});
const event = new KeyboardEvent("keydown", {
key: "c",
bubbles: true,
cancelable: true,
});
event.preventDefault();
document.dispatchEvent(event);
expect(onNewIssue).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
});

View file

@ -6,6 +6,7 @@ interface ShortcutHandlers {
onNewIssue?: () => void;
onToggleSidebar?: () => void;
onTogglePanel?: () => void;
onShowShortcuts?: () => void;
}
export function useKeyboardShortcuts({
@ -13,16 +14,28 @@ export function useKeyboardShortcuts({
onNewIssue,
onToggleSidebar,
onTogglePanel,
onShowShortcuts,
}: ShortcutHandlers) {
useEffect(() => {
if (!enabled) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.defaultPrevented) {
return;
}
// Don't fire shortcuts when typing in inputs
if (isKeyboardShortcutTextInputTarget(e.target)) {
return;
}
// ? → Show keyboard shortcuts cheatsheet
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
onShowShortcuts?.();
return;
}
// C → New Issue
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
@ -44,5 +57,5 @@ export function useKeyboardShortcuts({
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]);
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel, onShowShortcuts]);
}

View file

@ -294,26 +294,29 @@
}
}
/* Shimmer text effect for active "Working" state */
/* Shimmer text effect for active "Working" state — Cursor-style sweep */
@keyframes shimmer-text-slide {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
0% { background-position: 100% center; }
60% { background-position: 0% center; }
100% { background-position: 0% center; }
}
.shimmer-text {
--shimmer-base: hsl(var(--foreground) / 0.75);
--shimmer-highlight: hsl(var(--foreground) / 0.3);
--shimmer-base: var(--foreground);
--shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent);
background: linear-gradient(
110deg,
var(--shimmer-base) 35%,
90deg,
var(--shimmer-base) 0%,
var(--shimmer-base) 40%,
var(--shimmer-highlight) 50%,
var(--shimmer-base) 65%
var(--shimmer-base) 60%,
var(--shimmer-base) 100%
);
background-size: 250% 100%;
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer-text-slide 2.5s ease-in-out infinite;
animation: shimmer-text-slide 2.5s linear infinite;
}
@media (prefers-reduced-motion: reduce) {

View file

@ -9,16 +9,23 @@ import {
describe("company routes", () => {
it("treats execution workspace paths as board routes that need a company prefix", () => {
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/issues")).toBe(true);
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
"/PAP/execution-workspaces/workspace-123",
);
expect(applyCompanyPrefix("/execution-workspaces/workspace-123/issues", "PAP")).toBe(
"/PAP/execution-workspaces/workspace-123/issues",
);
});
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
"/execution-workspaces/workspace-123",
);
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/configuration")).toBe(
"/execution-workspaces/workspace-123/configuration",
);
});
/**

View file

@ -26,6 +26,7 @@ import {
loadLastInboxTab,
normalizeInboxIssueColumns,
RECENT_ISSUES_LIMIT,
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex,
saveInboxIssueColumns,
@ -552,6 +553,19 @@ describe("inbox helpers", () => {
expect(loadLastInboxTab()).toBe("all");
});
it("keeps nesting enabled on desktop when the saved preference is on", () => {
expect(resolveInboxNestingEnabled(true, false)).toBe(true);
});
it("forces nesting off on mobile even when the saved preference is on", () => {
expect(resolveInboxNestingEnabled(true, true)).toBe(false);
});
it("keeps nesting off when the saved preference is off", () => {
expect(resolveInboxNestingEnabled(false, false)).toBe(false);
expect(resolveInboxNestingEnabled(false, true)).toBe(false);
});
it("defaults issue columns to the current inbox layout", () => {
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
});

View file

@ -14,6 +14,7 @@ export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
@ -177,6 +178,27 @@ export function resolveIssueWorkspaceName(
return null;
}
export function loadInboxNesting(): boolean {
try {
const raw = localStorage.getItem(INBOX_NESTING_KEY);
return raw !== "false";
} catch {
return true;
}
}
export function saveInboxNesting(enabled: boolean) {
try {
localStorage.setItem(INBOX_NESTING_KEY, String(enabled));
} catch {
// Ignore localStorage failures.
}
}
export function resolveInboxNestingEnabled(preferenceEnabled: boolean, isMobile: boolean): boolean {
return preferenceEnabled && !isMobile;
}
export function loadLastInboxTab(): InboxTab {
try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
@ -340,6 +362,68 @@ export function getInboxWorkItems({
});
}
/**
* Groups parent-child issues in a flat InboxWorkItem list.
*
* - Children whose parent is also in the list are removed from the top level
* and stored in `childrenByIssueId`.
* - The parent's sort timestamp becomes max(parent, children) so that a group
* with a recently-updated child floats to the top.
* - If a parent is absent (e.g. archived), children remain as independent roots.
*/
export function buildInboxNesting(items: InboxWorkItem[]): {
displayItems: InboxWorkItem[];
childrenByIssueId: Map<string, Issue[]>;
} {
const issueItems: (InboxWorkItem & { kind: "issue" })[] = [];
const nonIssueItems: InboxWorkItem[] = [];
for (const item of items) {
if (item.kind === "issue") issueItems.push(item as InboxWorkItem & { kind: "issue" });
else nonIssueItems.push(item);
}
const issueIdSet = new Set(issueItems.map((i) => i.issue.id));
const childrenByIssueId = new Map<string, Issue[]>();
const childIds = new Set<string>();
for (const item of issueItems) {
const { issue } = item;
if (issue.parentId && issueIdSet.has(issue.parentId)) {
childIds.add(issue.id);
const arr = childrenByIssueId.get(issue.parentId) ?? [];
arr.push(issue);
childrenByIssueId.set(issue.parentId, arr);
}
}
// Sort each child list by most recent activity
for (const children of childrenByIssueId.values()) {
children.sort(sortIssuesByMostRecentActivity);
}
// Build root issue items with group-adjusted timestamps
const rootIssueItems: InboxWorkItem[] = issueItems
.filter((item) => !childIds.has(item.issue.id))
.map((item) => {
const children = childrenByIssueId.get(item.issue.id);
if (!children?.length) return item;
const maxChildTs = Math.max(...children.map(issueLastActivityTimestamp));
return { ...item, timestamp: Math.max(item.timestamp, maxChildTs) };
});
// Merge and re-sort
const displayItems = [...rootIssueItems, ...nonIssueItems].sort((a, b) => {
const diff = b.timestamp - a.timestamp;
if (diff !== 0) return diff;
if (a.kind === "issue" && b.kind === "issue") {
return sortIssuesByMostRecentActivity(a.issue, b.issue);
}
return 0;
});
return { displayItems, childrenByIssueId };
}
export function shouldShowInboxSection({
tab,
hasItems,

View file

@ -307,7 +307,7 @@ describe("buildIssueChatMessages", () => {
"system:activity:event-1",
"user:comment-1",
"assistant:comment-2",
"assistant:live-run:run-live-1",
"assistant:run-assistant:run-live-1",
]);
const liveRunMessage = messages.at(-1);
@ -353,7 +353,7 @@ describe("buildIssueChatMessages", () => {
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
id: "historical-run:run-history-1",
id: "run-assistant:run-history-1",
role: "assistant",
status: { type: "complete", reason: "stop" },
metadata: {
@ -370,6 +370,64 @@ describe("buildIssueChatMessages", () => {
]);
});
it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => {
const liveMessages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [],
liveRuns: [
{
id: "run-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:01:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:01:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
},
],
transcriptsByRunId: new Map([
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
]),
hasOutputForRun: (runId) => runId === "run-1",
currentUserId: "user-1",
});
const cancelledMessages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-1",
status: "cancelled",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:01:08.000Z"),
},
],
liveRuns: [],
transcriptsByRunId: new Map([
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
]),
hasOutputForRun: (runId) => runId === "run-1",
currentUserId: "user-1",
});
expect(liveMessages).toHaveLength(1);
expect(cancelledMessages).toHaveLength(1);
expect(liveMessages[0]).toMatchObject({ id: "run-assistant:run-1", status: { type: "running" } });
expect(cancelledMessages[0]).toMatchObject({
id: "run-assistant:run-1",
status: { type: "complete", reason: "stop" },
metadata: { custom: { runStatus: "cancelled" } },
});
});
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
const messages = buildIssueChatMessages({
comments: [],

View file

@ -410,7 +410,7 @@ function createHistoricalTranscriptMessage(args: {
: [];
const message: ThreadAssistantMessage = {
id: `historical-run:${run.runId}`,
id: `run-assistant:${run.runId}`,
role: "assistant",
createdAt: toDate(run.startedAt ?? run.createdAt),
content,
@ -593,25 +593,20 @@ function normalizeLiveRuns(
function createLiveRunMessage(args: {
run: LiveRunForIssue;
transcript: readonly IssueChatTranscriptEntry[];
hasOutput: boolean;
}) {
const { run, transcript, hasOutput } = args;
const { run, transcript } = args;
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
const waitingText =
run.status === "queued"
? "Queued..."
: hasOutput
: parts.length > 0
? ""
: "Working...";
const content = parts.length > 0
? parts
: waitingText
? [{ type: "text", text: waitingText } satisfies TextMessagePart]
: [];
const content = parts;
const message: ThreadAssistantMessage = {
id: `live-run:${run.id}`,
id: `run-assistant:${run.id}`,
role: "assistant",
createdAt: toDate(run.startedAt ?? run.createdAt),
content,
@ -684,7 +679,10 @@ export function buildIssueChatMessages(args: {
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false);
if (hasRunOutput) {
if (hasRunOutput || run.status !== "succeeded") {
// Always use the transcript message for non-succeeded runs (even before
// transcript data loads) so the message type and fold header are stable
// from initial render — avoids a flash when transcripts arrive later.
orderedMessages.push({
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
order: 2,
@ -697,7 +695,7 @@ export function buildIssueChatMessages(args: {
});
continue;
}
if (run.status === "succeeded" && !includeSucceededRunsWithoutOutput) continue;
if (!includeSucceededRunsWithoutOutput) continue;
orderedMessages.push({
createdAtMs: toTimestamp(runTimestamp(run)),
order: 2,
@ -712,7 +710,6 @@ export function buildIssueChatMessages(args: {
message: createLiveRunMessage({
run,
transcript: transcriptsByRunId?.get(run.id) ?? [],
hasOutput: hasOutputForRun?.(run.id) ?? false,
}),
});
}

View file

@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import {
hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget,
resolveIssueDetailGoKeyAction,
resolveInboxQuickArchiveKeyAction,
} from "./keyboardShortcuts";
@ -54,7 +55,7 @@ describe("keyboardShortcuts helpers", () => {
})).toBe("archive");
});
it("disarms on the first non-y keypress", () => {
it("ignores non-y keypresses", () => {
const button = document.createElement("button");
expect(resolveInboxQuickArchiveKeyAction({
@ -66,7 +67,7 @@ describe("keyboardShortcuts helpers", () => {
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("disarm");
})).toBe("ignore");
});
it("stays inert for modifier combos before a real keypress", () => {
@ -95,7 +96,7 @@ describe("keyboardShortcuts helpers", () => {
})).toBe("ignore");
});
it("disarms instead of archiving when typing into an editor", () => {
it("ignores input typing instead of archiving", () => {
const input = document.createElement("input");
expect(resolveInboxQuickArchiveKeyAction({
@ -107,6 +108,66 @@ describe("keyboardShortcuts helpers", () => {
altKey: false,
target: input,
hasOpenDialog: false,
})).toBe("ignore");
});
it("arms go-to-inbox on a clean g press", () => {
const button = document.createElement("button");
expect(resolveIssueDetailGoKeyAction({
armed: false,
defaultPrevented: false,
key: "g",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("arm");
});
it("navigates to inbox on i after g", () => {
const button = document.createElement("button");
expect(resolveIssueDetailGoKeyAction({
armed: true,
defaultPrevented: false,
key: "i",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("navigate_inbox");
});
it("focuses the comment composer on c after g", () => {
const button = document.createElement("button");
expect(resolveIssueDetailGoKeyAction({
armed: true,
defaultPrevented: false,
key: "c",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("focus_comment");
});
it("disarms go-to-inbox instead of firing from an editor", () => {
const input = document.createElement("textarea");
expect(resolveIssueDetailGoKeyAction({
armed: true,
defaultPrevented: false,
key: "i",
metaKey: false,
ctrlKey: false,
altKey: false,
target: input,
hasOpenDialog: false,
})).toBe("disarm");
});
});

View file

@ -11,6 +11,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
export type IssueDetailGoKeyAction = "ignore" | "arm" | "navigate_inbox" | "focus_comment" | "disarm";
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
@ -46,9 +47,42 @@ export function resolveInboxQuickArchiveKeyAction({
hasOpenDialog: boolean;
}): InboxQuickArchiveKeyAction {
if (!armed) return "ignore";
if (defaultPrevented) return "disarm";
if (defaultPrevented) return "ignore";
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm";
if (key === "y") return "archive";
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "ignore";
if (key.toLowerCase() === "y") return "archive";
return "ignore";
}
export function resolveIssueDetailGoKeyAction({
armed,
defaultPrevented,
key,
metaKey,
ctrlKey,
altKey,
target,
hasOpenDialog,
}: {
armed: boolean;
defaultPrevented: boolean;
key: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
target: EventTarget | null;
hasOpenDialog: boolean;
}): IssueDetailGoKeyAction {
if (defaultPrevented) return armed ? "disarm" : "ignore";
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) {
return armed ? "disarm" : "ignore";
}
const normalizedKey = key.toLowerCase();
if (!armed) return normalizedKey === "g" ? "arm" : "ignore";
if (normalizedKey === "i") return "navigate_inbox";
if (normalizedKey === "c") return "focus_comment";
if (normalizedKey === "g") return "arm";
return "disarm";
}

View file

@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
scheduleMainContentFocus,
shouldFocusMainContentAfterNavigation,
} from "./main-content-focus";
describe("main-content-focus", () => {
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
let originalCancelAnimationFrame: typeof window.cancelAnimationFrame;
beforeEach(() => {
document.body.innerHTML = "";
originalRequestAnimationFrame = window.requestAnimationFrame;
originalCancelAnimationFrame = window.cancelAnimationFrame;
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((handle: number) => window.clearTimeout(handle)) as typeof window.cancelAnimationFrame;
});
afterEach(() => {
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
document.body.innerHTML = "";
vi.restoreAllMocks();
});
it("prefers the main content when navigation leaves focus outside it", async () => {
const sidebarButton = document.createElement("button");
const main = document.createElement("main");
main.tabIndex = -1;
document.body.append(sidebarButton, main);
sidebarButton.focus();
scheduleMainContentFocus(main);
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(document.activeElement).toBe(main);
});
it("does not steal focus from an active element already inside main content", async () => {
const main = document.createElement("main");
const input = document.createElement("input");
main.tabIndex = -1;
main.appendChild(input);
document.body.append(main);
input.focus();
scheduleMainContentFocus(main);
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(document.activeElement).toBe(input);
});
it("treats disconnected elements as needing main-content focus", () => {
const main = document.createElement("main");
main.tabIndex = -1;
document.body.append(main);
const staleButton = document.createElement("button");
staleButton.focus();
expect(shouldFocusMainContentAfterNavigation(main, staleButton)).toBe(true);
});
});

View file

@ -0,0 +1,21 @@
export function shouldFocusMainContentAfterNavigation(
mainElement: HTMLElement | null,
activeElement: Element | null,
): boolean {
if (!(mainElement instanceof HTMLElement)) return false;
if (!(activeElement instanceof HTMLElement)) return true;
if (!document.contains(activeElement)) return true;
if (activeElement === document.body || activeElement === document.documentElement) return true;
return !mainElement.contains(activeElement);
}
export function scheduleMainContentFocus(mainElement: HTMLElement | null): () => void {
if (!(mainElement instanceof HTMLElement)) return () => {};
const frame = window.requestAnimationFrame(() => {
if (!shouldFocusMainContentAfterNavigation(mainElement, document.activeElement)) return;
mainElement.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frame);
}

View file

@ -1,10 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Issue } from "@paperclipai/shared";
import {
applyOptimisticIssueFieldUpdate,
applyOptimisticIssueFieldUpdateToCollection,
applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment,
flattenIssueCommentPages,
getNextIssueCommentPageParam,
isQueuedIssueComment,
matchesIssueRef,
mergeIssueComments,
upsertIssueComment,
upsertIssueCommentInPages,
} from "./optimistic-issue-comments";
describe("optimistic issue comments", () => {
@ -124,6 +131,125 @@ describe("optimistic issue comments", () => {
expect(next[0]?.body).toBe("Updated");
});
it("flattens paged comments into one chronological thread", () => {
const flattened = flattenIssueCommentPages([
[
{
id: "comment-3",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
],
[
{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
{
id: "comment-2",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Middle",
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
],
]);
expect(flattened.map((comment) => comment.id)).toEqual(["comment-1", "comment-2", "comment-3"]);
});
it("returns no next page param when the last page is missing", () => {
expect(getNextIssueCommentPageParam(undefined, 50)).toBeUndefined();
});
it("returns the oldest id when the last page is full", () => {
expect(
getNextIssueCommentPageParam(
[
{
id: "comment-2",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "First",
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
],
2,
),
).toBe("comment-1");
});
it("upserts paged comments without dropping older pages", () => {
const nextPages = upsertIssueCommentInPages(
[
[
{
id: "comment-3",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
],
[
{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
],
],
{
id: "comment-4",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Brand new",
createdAt: new Date("2026-03-28T14:00:04.000Z"),
updatedAt: new Date("2026-03-28T14:00:04.000Z"),
},
);
expect(nextPages[0]?.map((comment) => comment.id)).toEqual(["comment-4", "comment-3"]);
expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]);
});
it("applies optimistic reopen and reassignment updates to the issue cache", () => {
const next = applyOptimisticIssueCommentUpdate(
{
@ -177,6 +303,267 @@ describe("optimistic issue comments", () => {
expect(next?.assigneeUserId).toBe("board-2");
});
it("applies optimistic field updates for issue property edits", () => {
const next = applyOptimisticIssueFieldUpdate(
{
id: "issue-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
goalId: null,
parentId: null,
title: "Fix property pane",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "board-1",
issueNumber: 1,
identifier: "PAP-1",
originKind: "manual",
originId: null,
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: "exec-1",
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labelIds: ["label-1", "label-2"],
labels: [
{
id: "label-1",
companyId: "company-1",
name: "One",
color: "#111111",
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
{
id: "label-2",
companyId: "company-1",
name: "Two",
color: "#222222",
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
],
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "First blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-3",
identifier: "PAP-3",
title: "Second blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
project: {
id: "project-1",
companyId: "company-1",
urlKey: "project-one",
goalId: null,
goalIds: [],
goals: [],
name: "Project one",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/paperclip",
effectiveLocalFolder: "/tmp/paperclip",
origin: "local_folder",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
currentExecutionWorkspace: {
id: "exec-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: "issue-1",
mode: "shared_workspace",
strategyType: "project_primary",
branchName: null,
status: "active",
name: "Execution workspace",
cwd: "/tmp/paperclip",
repoUrl: null,
baseRef: null,
providerType: "local_fs",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date("2026-03-28T14:00:00.000Z"),
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
openedAt: new Date("2026-03-28T14:00:00.000Z"),
closedAt: null,
},
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
{
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "board-2",
labelIds: ["label-2"],
blockedByIssueIds: ["issue-3"],
projectId: "project-2",
executionWorkspaceId: "exec-2",
},
);
expect(next?.status).toBe("in_review");
expect(next?.assigneeAgentId).toBeNull();
expect(next?.assigneeUserId).toBe("board-2");
expect(next?.labelIds).toEqual(["label-2"]);
expect(next?.labels?.map((label) => label.id)).toEqual(["label-2"]);
expect(next?.blockedBy?.map((relation) => relation.id)).toEqual(["issue-3"]);
expect(next?.projectId).toBe("project-2");
expect(next?.project).toBeNull();
expect(next?.executionWorkspaceId).toBe("exec-2");
expect(next?.currentExecutionWorkspace).toBeNull();
});
it("matches issues by either uuid or identifier reference", () => {
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["issue-1"])).toBe(true);
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["PAP-1"])).toBe(true);
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["issue-2", "PAP-2"])).toBe(false);
});
it("applies optimistic field updates across cached issue collections", () => {
const issues: Issue[] = [
{
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Fix property pane",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "board-1",
issueNumber: 1,
identifier: "PAP-1",
originKind: "manual",
originId: null,
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labelIds: [],
labels: [],
blockedBy: [],
blocks: [],
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
{
id: "issue-2",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Leave me alone",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: "agent-2",
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "board-1",
issueNumber: 2,
identifier: "PAP-2",
originKind: "manual",
originId: null,
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labelIds: [],
labels: [],
blockedBy: [],
blocks: [],
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
];
const next = applyOptimisticIssueFieldUpdateToCollection(issues, ["PAP-1"], { assigneeAgentId: "agent-9" });
expect(next?.[0]?.assigneeAgentId).toBe("agent-9");
expect(next?.[1]?.assigneeAgentId).toBe("agent-2");
});
it("treats comments without a run id as queued when they arrive during an active run", () => {
expect(
isQueuedIssueComment({

View file

@ -33,6 +33,10 @@ export function sortIssueComments<T extends { createdAt: Date | string; id: stri
});
}
function sortIssueCommentsDesc<T extends { createdAt: Date | string; id: string }>(comments: T[]) {
return sortIssueComments(comments).reverse();
}
export function createOptimisticIssueComment(params: {
companyId: string;
issueId: string;
@ -92,6 +96,20 @@ export function mergeIssueComments(
return sortIssueComments(merged);
}
export function flattenIssueCommentPages(
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
): IssueComment[] {
return sortIssueComments((pages ?? []).flatMap((page) => page));
}
export function getNextIssueCommentPageParam(
lastPage: ReadonlyArray<IssueComment> | undefined,
pageSize: number,
): string | undefined {
if (!lastPage || lastPage.length < pageSize) return undefined;
return lastPage[lastPage.length - 1]?.id;
}
export function upsertIssueComment(
comments: IssueComment[] | undefined,
nextComment: IssueComment,
@ -128,3 +146,106 @@ export function applyOptimisticIssueCommentUpdate(
return nextIssue;
}
export function applyOptimisticIssueFieldUpdate(
issue: Issue | undefined,
data: Record<string, unknown>,
) {
if (!issue) return issue;
const nextIssue: Issue = {
...issue,
updatedAt: new Date(),
};
const hasOwn = (key: string) => Object.prototype.hasOwnProperty.call(data, key);
const assign = <K extends keyof Issue>(key: K) => {
if (hasOwn(key)) {
nextIssue[key] = data[key] as Issue[K];
}
};
assign("status");
assign("priority");
assign("assigneeAgentId");
assign("assigneeUserId");
assign("projectId");
assign("projectWorkspaceId");
assign("executionWorkspaceId");
assign("executionWorkspacePreference");
assign("executionWorkspaceSettings");
assign("hiddenAt");
if (hasOwn("labelIds") && Array.isArray(data.labelIds)) {
const nextLabelIds = data.labelIds.filter((value): value is string => typeof value === "string");
nextIssue.labelIds = nextLabelIds;
if (issue.labels) {
nextIssue.labels = issue.labels.filter((label) => nextLabelIds.includes(label.id));
}
}
if (hasOwn("blockedByIssueIds") && Array.isArray(data.blockedByIssueIds) && issue.blockedBy) {
const nextBlockedByIds = new Set(
data.blockedByIssueIds.filter((value): value is string => typeof value === "string"),
);
nextIssue.blockedBy = issue.blockedBy.filter((relation) => nextBlockedByIds.has(relation.id));
}
if (hasOwn("projectId")) {
nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null;
}
if (hasOwn("executionWorkspaceId")) {
nextIssue.currentExecutionWorkspace =
issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId
? issue.currentExecutionWorkspace
: null;
}
return nextIssue;
}
export function matchesIssueRef(
issue: Pick<Issue, "id" | "identifier">,
refs: Iterable<string>,
) {
const refSet = refs instanceof Set ? refs : new Set(refs);
return refSet.has(issue.id) || (!!issue.identifier && refSet.has(issue.identifier));
}
export function applyOptimisticIssueFieldUpdateToCollection(
issues: Issue[] | undefined,
refs: Iterable<string>,
data: Record<string, unknown>,
) {
if (!issues) return issues;
let changed = false;
const nextIssues = issues.map((issue) => {
if (!matchesIssueRef(issue, refs)) return issue;
changed = true;
return applyOptimisticIssueFieldUpdate(issue, data) ?? issue;
});
return changed ? nextIssues : issues;
}
export function upsertIssueCommentInPages(
pages: ReadonlyArray<ReadonlyArray<IssueComment>> | undefined,
nextComment: IssueComment,
): IssueComment[][] {
if (!pages || pages.length === 0) {
return [[nextComment]];
}
const nextPages = pages.map((page) => [...page]);
for (let pageIndex = 0; pageIndex < nextPages.length; pageIndex += 1) {
const existingIndex = nextPages[pageIndex]!.findIndex((comment) => comment.id === nextComment.id);
if (existingIndex === -1) continue;
nextPages[pageIndex]![existingIndex] = nextComment;
nextPages[pageIndex] = sortIssueCommentsDesc(nextPages[pageIndex]!);
return nextPages;
}
nextPages[0] = sortIssueCommentsDesc([...nextPages[0]!, nextComment]);
return nextPages;
}

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import type { RunForIssue } from "../api/activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { removeLiveRunById, upsertInterruptedRun } from "./optimistic-issue-runs";
function createLiveRun(overrides: Partial<LiveRunForIssue> = {}): LiveRunForIssue {
return {
id: "run-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-08T21:00:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
...overrides,
};
}
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
return {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
invocationSource: "on_demand",
triggerDetail: null,
status: "running",
startedAt: new Date("2026-04-08T21:00:00.000Z"),
finishedAt: null,
error: null,
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: { inputTokens: 1 },
resultJson: { summary: "partial" },
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
stdoutExcerpt: null,
stderrExcerpt: null,
errorCode: null,
externalRunId: null,
processPid: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
contextSnapshot: null,
createdAt: new Date("2026-04-08T21:00:00.000Z"),
updatedAt: new Date("2026-04-08T21:00:00.000Z"),
...overrides,
};
}
describe("upsertInterruptedRun", () => {
it("adds a synthetic cancelled historical run when the live run has not reached linkedRuns yet", () => {
const runs = upsertInterruptedRun(undefined, createLiveRun(), "2026-04-08T21:00:10.000Z");
expect(runs).toEqual([{
runId: "run-1",
status: "cancelled",
agentId: "agent-1",
startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: "2026-04-08T21:00:10.000Z",
createdAt: "2026-04-08T21:00:00.000Z",
invocationSource: "manual",
usageJson: null,
resultJson: null,
}]);
});
it("updates an existing linked run in place when the interrupted run is already present", () => {
const existing: RunForIssue[] = [{
runId: "run-1",
status: "running",
agentId: "agent-1",
startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-08T21:00:00.000Z",
invocationSource: "manual",
usageJson: { inputTokens: 2 },
resultJson: { summary: "partial" },
}];
const runs = upsertInterruptedRun(existing, createActiveRun(), "2026-04-08T21:00:11.000Z");
expect(runs).toEqual([{
runId: "run-1",
status: "cancelled",
agentId: "agent-1",
startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: "2026-04-08T21:00:11.000Z",
createdAt: "2026-04-08T21:00:00.000Z",
invocationSource: "on_demand",
usageJson: { inputTokens: 2 },
resultJson: { summary: "partial" },
}]);
});
});
describe("removeLiveRunById", () => {
it("removes an interrupted live run from the live list", () => {
const runs = removeLiveRunById([
createLiveRun(),
createLiveRun({ id: "run-2" }),
], "run-1");
expect(runs?.map((run) => run.id)).toEqual(["run-2"]);
});
});

View file

@ -0,0 +1,68 @@
import type { RunForIssue } from "../api/activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
export interface InterruptRunSource {
id: string;
agentId: string;
startedAt: Date | string | null;
createdAt: Date | string;
invocationSource: string;
usageJson?: Record<string, unknown> | null;
resultJson?: Record<string, unknown> | null;
}
function toTimestamp(value: Date | string | null | undefined) {
if (!value) return 0;
return new Date(value).getTime();
}
function toIsoString(value: Date | string | null | undefined) {
if (!value) return null;
return value instanceof Date ? value.toISOString() : value;
}
export function upsertInterruptedRun(
runs: RunForIssue[] | undefined,
run: InterruptRunSource,
finishedAt: string,
): RunForIssue[] {
const nextRun: RunForIssue = {
runId: run.id,
status: "cancelled",
agentId: run.agentId,
startedAt: toIsoString(run.startedAt),
finishedAt,
createdAt: toIsoString(run.createdAt) ?? finishedAt,
invocationSource: run.invocationSource,
usageJson: run.usageJson ?? null,
resultJson: run.resultJson ?? null,
};
const current = runs ?? [];
const existingIndex = current.findIndex((entry) => entry.runId === run.id);
if (existingIndex === -1) {
return [...current, nextRun].sort((a, b) => {
const diff = toTimestamp(a.startedAt ?? a.createdAt) - toTimestamp(b.startedAt ?? b.createdAt);
if (diff !== 0) return diff;
return a.runId.localeCompare(b.runId);
});
}
const updated = [...current];
updated[existingIndex] = {
...updated[existingIndex],
...nextRun,
usageJson: updated[existingIndex]?.usageJson ?? nextRun.usageJson,
resultJson: updated[existingIndex]?.resultJson ?? nextRun.resultJson,
};
return updated;
}
export function removeLiveRunById(
runs: LiveRunForIssue[] | undefined,
runId: string,
) {
if (!runs) return runs;
const nextRuns = runs.filter((run) => run.id !== runId);
return nextRuns.length === runs.length ? runs : nextRuns;
}

View file

@ -39,6 +39,8 @@ export const queryKeys = {
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
listByProject: (companyId: string, projectId: string) =>
["issues", companyId, "project", projectId] as const,
listByParent: (companyId: string, parentId: string) =>
["issues", companyId, "parent", parentId] as const,
listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) =>
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
detail: (id: string) => ["issues", "detail", id] as const,

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@ import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useGeneralSettings } from "../context/GeneralSettingsContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import {
armIssueDetailInboxQuickArchive,
@ -26,17 +27,21 @@ import {
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import {
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
IssueColumnPicker,
issueActivityText,
issueTrailingColumns,
} from "../components/IssueColumns";
import { IssueRow } from "../components/IssueRow";
import { SwipeToArchive } from "../components/SwipeToArchive";
import { StatusIcon } from "../components/StatusIcon";
import { cn } from "../lib/utils";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { timeAgo } from "../lib/timeAgo";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -48,15 +53,6 @@ import {
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Tabs } from "@/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
@ -67,12 +63,13 @@ import {
import {
Inbox as InboxIcon,
AlertTriangle,
ChevronRight,
XCircle,
X,
RotateCcw,
UserPlus,
Columns3,
Search,
ListTree,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { PageTabBar } from "../components/PageTabBar";
@ -80,6 +77,7 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh
import {
ACTIONABLE_APPROVAL_STATUSES,
DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxNesting,
getAvailableInboxIssueColumns,
getApprovalsForTab,
getInboxWorkItems,
@ -89,10 +87,13 @@ import {
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
loadInboxNesting,
normalizeInboxIssueColumns,
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex,
saveInboxIssueColumns,
saveInboxNesting,
InboxApprovalFilter,
type InboxIssueColumn,
saveLastInboxTab,
@ -102,6 +103,8 @@ import {
} from "../lib/inbox";
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
@ -113,6 +116,11 @@ type SectionKey =
| "work_items"
| "alerts";
/** A flat navigation entry for keyboard j/k traversal that includes expanded children. */
type NavEntry =
| { type: "top"; index: number; item: InboxWorkItem }
| { type: "child"; parentIndex: number; issue: Issue };
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@ -142,245 +150,6 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
status: "Status",
id: "ID",
assignee: "Assignee",
project: "Project",
workspace: "Workspace",
parent: "Parent issue",
labels: "Tags",
updated: "Last updated",
};
const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
status: "Issue state chip on the left edge.",
id: "Ticket identifier like PAP-1009.",
assignee: "Assigned agent or board user.",
project: "Linked project pill with its color.",
workspace: "Execution or project workspace used for the issue.",
parent: "Parent issue identifier and title.",
labels: "Issue labels and tags.",
updated: "Latest visible activity time.",
};
export function InboxIssueMetaLeading({
issue,
isLive,
showStatus = true,
showIdentifier = true,
}: {
issue: Issue;
isLive: boolean;
showStatus?: boolean;
showIdentifier?: boolean;
}) {
return (
<>
{showStatus ? (
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
) : null}
{showIdentifier ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
) : null}
{isLive && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
"bg-blue-500/10",
)}
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
"bg-blue-500",
)}
/>
</span>
<span
className={cn(
"hidden text-[11px] font-medium sm:inline",
"text-blue-600 dark:text-blue-400",
)}
>
Live
</span>
</span>
)}
</>
);
}
function issueActivityText(issue: Issue): string {
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
}
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
return columns
.map((column) => {
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
if (column === "project") return "minmax(6.5rem, 8.5rem)";
if (column === "workspace") return "minmax(9rem, 12rem)";
if (column === "parent") return "minmax(5rem, 7rem)";
if (column === "labels") return "minmax(8rem, 10rem)";
return "minmax(4rem, 5.5rem)";
})
.join(" ");
}
export function InboxIssueTrailingColumns({
issue,
columns,
projectName,
projectColor,
workspaceName,
assigneeName,
currentUserId,
parentIdentifier,
parentTitle,
}: {
issue: Issue;
columns: InboxIssueColumn[];
projectName: string | null;
projectColor: string | null;
workspaceName: string | null;
assigneeName: string | null;
currentUserId: string | null;
parentIdentifier: string | null;
parentTitle: string | null;
}) {
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
return (
<span
className="grid items-center gap-2"
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
>
{columns.map((column) => {
if (column === "assignee") {
if (issue.assigneeAgentId) {
return (
<span key={column} className="min-w-0 text-xs text-foreground">
<Identity
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
size="sm"
className="min-w-0"
/>
</span>
);
}
if (issue.assigneeUserId) {
return (
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
{userLabel}
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
Unassigned
</span>
);
}
if (column === "project") {
if (projectName) {
const accentColor = projectColor ?? "#64748b";
return (
<span
key={column}
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span className="truncate">{projectName}</span>
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
No project
</span>
);
}
if (column === "labels") {
if ((issue.labels ?? []).length > 0) {
return (
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
{(issue.labels ?? []).slice(0, 2).map((label) => (
<span
key={label.id}
className="inline-flex min-w-0 max-w-full items-center font-medium"
style={{
color: pickTextColorForPillBg(label.color, 0.12),
}}
>
<span className="truncate">{label.name}</span>
</span>
))}
{(issue.labels ?? []).length > 2 ? (
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
+{(issue.labels ?? []).length - 2}
</span>
) : null}
</span>
);
}
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
if (column === "workspace") {
if (!workspaceName) {
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
{workspaceName}
</span>
);
}
if (column === "parent") {
if (!issue.parentId) {
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
{parentIdentifier ? (
<span className="font-mono">{parentIdentifier}</span>
) : (
<span className="italic">Sub-issue</span>
)}
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
{activityText}
</span>
);
})}
</span>
);
}
export function FailedRunInboxRow({
run,
@ -813,6 +582,7 @@ function JoinRequestInboxRow({
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { isMobile } = useSidebar();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
@ -1029,7 +799,7 @@ export function Inbox() {
);
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
const visibleTrailingIssueColumns = useMemo(
() => trailingIssueColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
[availableIssueColumnSet, visibleIssueColumnSet],
);
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
@ -1154,6 +924,51 @@ export function Inbox() {
projectWorkspaceById,
]);
// --- Parent-child nesting for inbox issues ---
const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting());
const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile);
const toggleNesting = useCallback(() => {
setNestingPreferenceEnabled((prev) => {
const next = !prev;
saveInboxNesting(next);
return next;
});
}, []);
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
const { displayItems: nestedWorkItems, childrenByIssueId } = useMemo(
() => nestingEnabled
? buildInboxNesting(filteredWorkItems)
: { displayItems: filteredWorkItems, childrenByIssueId: new Map<string, Issue[]>() },
[filteredWorkItems, nestingEnabled],
);
const toggleInboxParentCollapse = useCallback((parentId: string) => {
setCollapsedInboxParents((prev) => {
const next = new Set(prev);
if (next.has(parentId)) next.delete(parentId);
else next.add(parentId);
return next;
});
}, []);
// Build flat navigation list including expanded children for keyboard traversal
const flatNavItems = useMemo((): NavEntry[] => {
const entries: NavEntry[] = [];
for (let i = 0; i < nestedWorkItems.length; i++) {
const item = nestedWorkItems[i];
entries.push({ type: "top", index: i, item });
if (item.kind === "issue") {
const children = childrenByIssueId.get(item.issue.id);
const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id);
if (isExpanded) {
for (const child of children) {
entries.push({ type: "child", parentIndex: i, issue: child });
}
}
}
}
return entries;
}, [nestedWorkItems, childrenByIssueId, collapsedInboxParents]);
const agentName = (id: string | null) => {
if (!id) return null;
return agentById.get(id) ?? null;
@ -1427,12 +1242,13 @@ export function Inbox() {
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
useEffect(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, filteredWorkItems.length));
}, [filteredWorkItems.length]);
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, flatNavItems.length));
}, [flatNavItems.length]);
// Use refs for keyboard handler to avoid stale closures
const kbStateRef = useRef({
workItems: filteredWorkItems,
workItems: nestedWorkItems,
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
@ -1441,7 +1257,8 @@ export function Inbox() {
readItems,
});
kbStateRef.current = {
workItems: filteredWorkItems,
workItems: nestedWorkItems,
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
@ -1495,77 +1312,94 @@ export function Inbox() {
// Keyboard shortcuts are only active on the "mine" tab
if (!st.canArchive) return;
const itemCount = st.workItems.length;
if (itemCount === 0) return;
const navItems = st.flatNavItems;
const navCount = navItems.length;
if (navCount === 0) return;
/** Resolve the nav entry at selectedIndex to an issue (for child entries) or work item. */
const resolveNavEntry = (idx: number): { issue?: Issue; item?: InboxWorkItem } => {
const entry = navItems[idx];
if (!entry) return {};
if (entry.type === "child") return { issue: entry.issue };
return { item: entry.item };
};
switch (e.key) {
case "j": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "next"));
break;
}
case "k": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "previous"));
break;
}
case "a":
case "y": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) {
act.archiveNonIssue(key);
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
} else if (item) {
if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id);
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
}
}
break;
}
case "U": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
act.markUnreadIssue(item.issue.id);
} else {
act.markNonIssueUnread(getWorkItemKey(item));
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
act.markUnreadIssue(issue.id);
} else if (item) {
if (item.kind === "issue") act.markUnreadIssue(item.issue.id);
else act.markNonIssueUnread(getWorkItemKey(item));
}
break;
}
case "r": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
act.markRead(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.readItems.has(key)) {
act.markNonIssueRead(key);
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
if (issue.isUnreadForMe && !st.fadingOutIssues.has(issue.id)) act.markRead(issue.id);
} else if (item) {
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) act.markRead(item.issue.id);
} else {
const key = getWorkItemKey(item);
if (!st.readItems.has(key)) act.markNonIssueRead(key);
}
}
break;
}
case "Enter": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id;
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
const pathId = issue.identifier ?? issue.id;
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
} else if (item) {
if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id;
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
}
}
break;
}
@ -1601,7 +1435,7 @@ export function Inbox() {
dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissedAlerts.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert;
const showWorkItemsSection = filteredWorkItems.length > 0;
const showWorkItemsSection = nestedWorkItems.length > 0;
const showAlertsSection = shouldShowInboxSection({
tab,
hasItems: hasAlerts,
@ -1674,58 +1508,23 @@ export function Inbox() {
className="h-8 w-[220px] pl-8 text-xs"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground sm:inline-flex"
>
<Columns3 className="mr-1 h-3.5 w-3.5" />
Show / hide columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Desktop issue rows
</div>
<div className="text-sm font-medium text-foreground">
Choose which inbox columns stay visible
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableIssueColumns.map((column) => (
<DropdownMenuCheckboxItem
key={column}
checked={visibleIssueColumnSet.has(column)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => toggleIssueColumn(column, checked === true)}
className="items-start rounded-lg px-3 py-2.5 pl-8"
>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
{inboxIssueColumnLabels[column]}
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{inboxIssueColumnDescriptions[column]}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
className="rounded-lg px-3 py-2 text-sm"
>
Reset defaults
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="icon"
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
onClick={toggleNesting}
title={nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
>
<ListTree className="h-3.5 w-3.5" />
</Button>
<IssueColumnPicker
availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet}
onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
/>
{canMarkAllRead && (
<>
<Button
@ -1833,13 +1632,34 @@ export function Inbox() {
{showSeparatorBefore("work_items") && <Separator />}
<div>
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
{filteredWorkItems.flatMap((item, index) => {
{(() => {
// Pre-compute flat nav index for each top-level item and child issue
let flatIdx = 0;
const topFlatIndex = new Map<number, number>();
const childFlatIndex = new Map<string, number>();
for (let ti = 0; ti < nestedWorkItems.length; ti++) {
topFlatIndex.set(ti, flatIdx);
flatIdx++;
const topItem = nestedWorkItems[ti];
if (topItem.kind === "issue") {
const children = childrenByIssueId.get(topItem.issue.id);
const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id);
if (isExp) {
for (const c of children) {
childFlatIndex.set(c.id, flatIdx);
flatIdx++;
}
}
}
}
return nestedWorkItems.flatMap((item, index) => {
const navIdx = topFlatIndex.get(index) ?? index;
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
<div
key={`sel-${key}`}
data-inbox-item
className="relative"
onClick={() => setSelectedIndex(index)}
onClick={() => setSelectedIndex(navIdx)}
>
{child}
</div>
@ -1849,7 +1669,7 @@ export function Inbox() {
index > 0 &&
item.timestamp > 0 &&
item.timestamp < todayCutoff &&
filteredWorkItems[index - 1].timestamp >= todayCutoff;
nestedWorkItems[index - 1].timestamp >= todayCutoff;
const elements: ReactNode[] = [];
if (showTodayDivider) {
elements.push(
@ -1861,7 +1681,7 @@ export function Inbox() {
</div>,
);
}
const isSelected = selectedIndex === index;
const isSelected = selectedIndex === navIdx;
if (item.kind === "approval") {
const approvalKey = `approval:${item.approval.id}`;
@ -1973,74 +1793,150 @@ export function Inbox() {
}
const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
const isArchiving = archivingIssueIds.has(issue.id);
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
const row = (
<IssueRow
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
selected={isSelected}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
desktopMetaLeading={
<InboxIssueMetaLeading
issue={issue}
isLive={liveIssueIds.has(issue.id)}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
/>
}
mobileMeta={issueActivityText(issue).toLowerCase()}
unreadState={
isUnread ? "visible" : isFading ? "fading" : "hidden"
}
onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={
canArchiveFromTab
? () => archiveIssueMutation.mutate(issue.id)
: undefined
}
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
desktopTrailing={
visibleTrailingIssueColumns.length > 0 ? (
<InboxIssueTrailingColumns
issue={issue}
columns={visibleTrailingIssueColumns}
projectName={issueProject?.name ?? null}
projectColor={issueProject?.color ?? null}
workspaceName={resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(issue.assigneeAgentId)}
currentUserId={currentUserId}
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
/>
) : undefined
}
/>
);
const childIssues = childrenByIssueId.get(issue.id) ?? [];
const hasChildren = childIssues.length > 0;
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => {
const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id);
const isFading = fadingOutIssues.has(iss.id);
const isArch = archivingIssueIds.has(iss.id);
const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null;
return (
<IssueRow
key={`issue:${iss.id}`}
issue={iss}
issueLinkState={issueLinkState}
selected={sel}
className={
isArch
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
desktopMetaLeading={
<>
{nestingEnabled ? (
depth === 0 && hasChildren ? (
<button
type="button"
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleInboxParentCollapse(issue.id);
}}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="hidden w-4 shrink-0 sm:block" />
)
) : null}
{depth > 0 ? (
<span className="hidden w-4 shrink-0 sm:block" />
) : null}
<InboxIssueMetaLeading
issue={iss}
isLive={liveIssueIds.has(iss.id)}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
/>
</>
}
titleSuffix={hasChildren && !isExpanded && depth === 0 ? (
<span className="ml-1.5 text-xs text-muted-foreground">
({childIssues.length} sub-task{childIssues.length !== 1 ? "s" : ""})
</span>
) : undefined}
mobileMeta={issueActivityText(iss).toLowerCase()}
mobileLeading={
depth === 0 && hasChildren ? (
<button type="button" onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleInboxParentCollapse(issue.id);
}}>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : undefined
}
unreadState={
isUnread ? "visible" : isFading ? "fading" : "hidden"
}
onMarkRead={() => markReadMutation.mutate(iss.id)}
onArchive={
canArchiveFromTab
? () => archiveIssueMutation.mutate(iss.id)
: undefined
}
archiveDisabled={isArch || archiveIssueMutation.isPending}
desktopTrailing={
visibleTrailingIssueColumns.length > 0 ? (
<InboxIssueTrailingColumns
issue={iss}
columns={visibleTrailingIssueColumns}
projectName={proj?.name ?? null}
projectColor={proj?.color ?? null}
workspaceName={resolveIssueWorkspaceName(iss, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(iss.assigneeAgentId)}
currentUserId={currentUserId}
parentIdentifier={iss.parentId ? (issueById.get(iss.parentId)?.identifier ?? null) : null}
parentTitle={iss.parentId ? (issueById.get(iss.parentId)?.title ?? null) : null}
/>
) : undefined
}
/>
);
};
// Render parent issue
const parentRow = renderInboxIssue(issue, 0, isSelected);
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={`issue:${issue.id}`}
selected={isSelected}
disabled={isArchiving || archiveIssueMutation.isPending}
disabled={archivingIssueIds.has(issue.id) || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(issue.id)}
>
{row}
{parentRow}
</SwipeToArchive>
) : row));
) : parentRow));
// Render children if expanded
if (isExpanded) {
for (const child of childIssues) {
const cNavIdx = childFlatIndex.get(child.id) ?? -1;
const isChildSelected = selectedIndex === cNavIdx;
const childRow = renderInboxIssue(child, 1, isChildSelected);
const isChildArchiving = archivingIssueIds.has(child.id);
elements.push(
<div
key={`sel-issue:${child.id}`}
data-inbox-item
className="relative"
onClick={() => setSelectedIndex(cNavIdx)}
>
{canArchiveFromTab ? (
<SwipeToArchive
key={`issue:${child.id}`}
selected={isChildSelected}
disabled={isChildArchiving || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(child.id)}
>
{childRow}
</SwipeToArchive>
) : childRow}
</div>,
);
}
}
return elements;
})}
});
})()}
</div>
</div>
</>

View file

@ -17,7 +17,7 @@ import {
issueChatUxTranscriptsByRunId,
} from "../fixtures/issueChatUxFixtures";
import { cn } from "../lib/utils";
import { Bot, Brain, FlaskConical, MessagesSquare, Route, Sparkles, WandSparkles } from "lucide-react";
import { Bot, Brain, FlaskConical, Loader2, MessagesSquare, Route, Sparkles, WandSparkles } from "lucide-react";
const noop = async () => {};
@ -216,6 +216,43 @@ export function IssueChatUxLab() {
</div>
</LabSection>
<LabSection
id="working-tokens"
eyebrow="Status tokens"
title="Working / Worked header verb"
description='The "Working" token uses the shimmer-text gradient sweep to signal an active run. Once the run completes it becomes the static "Worked" token.'
accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_28%),var(--background)]"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-border/60 bg-accent/10 p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Active run shimmer
</div>
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<span className="shimmer-text">Working</span>
</span>
<span className="text-xs text-muted-foreground/60">for 12s</span>
</div>
</div>
<div className="rounded-xl border border-border/60 bg-accent/10 p-4">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Completed run static
</div>
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500/70" />
</span>
Worked
</span>
<span className="text-xs text-muted-foreground/60">for 1 min 24s</span>
</div>
</div>
</div>
</LabSection>
<LabSection
id="live-execution"
eyebrow="Primary preview"

File diff suppressed because it is too large Load diff

View file

@ -173,6 +173,11 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
enabled: !!companyId,
refetchInterval: 5000,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(companyId),
queryFn: () => projectsApi.list(companyId),
enabled: !!companyId,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
@ -203,6 +208,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
isLoading={isLoading}
error={error as Error | null}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
projectId={projectId}
viewStateKey={`paperclip:project-view:${projectId}`}