forked from farhoodlabs/paperclip
Merge pull request #3205 from cryppadotta/pap-1239-ui-ux
feat(ui): improve issue detail and inbox workflows
This commit is contained in:
@@ -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 />}>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
+17
-1
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+348
-154
@@ -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>
|
||||
|
||||
@@ -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 · Shortcuts are disabled in text fields
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
+13
-10
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
+307
-411
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
|
||||
+565
-202
File diff suppressed because it is too large
Load Diff
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user