mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
Merge pull request #3205 from cryppadotta/pap-1239-ui-ux
feat(ui): improve issue detail and inbox workflows
This commit is contained in:
commit
264eb34f24
46 changed files with 4688 additions and 1337 deletions
|
|
@ -161,6 +161,8 @@ function boardRoutes() {
|
||||||
<Route path="routines" element={<Routines />} />
|
<Route path="routines" element={<Routines />} />
|
||||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
<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" element={<Goals />} />
|
||||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
<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/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" 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/chat" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path=":companyPrefix" element={<Layout />}>
|
<Route path=":companyPrefix" element={<Layout />}>
|
||||||
|
|
|
||||||
26
ui/src/api/issues.test.ts
Normal file
26
ui/src/api/issues.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockApi = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./client", () => ({
|
||||||
|
api: mockApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { issuesApi } from "./issues";
|
||||||
|
|
||||||
|
describe("issuesApi.list", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApi.get.mockReset();
|
||||||
|
mockApi.get.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes parentId through to the company issues endpoint", async () => {
|
||||||
|
await issuesApi.list("company-1", { parentId: "issue-parent-1", limit: 25 });
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/companies/company-1/issues?parentId=issue-parent-1&limit=25",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,7 @@ export const issuesApi = {
|
||||||
filters?: {
|
filters?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
parentId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
participantAgentId?: string;
|
participantAgentId?: string;
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
|
|
@ -42,6 +43,7 @@ export const issuesApi = {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.status) params.set("status", filters.status);
|
if (filters?.status) params.set("status", filters.status);
|
||||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
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?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
||||||
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
||||||
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||||
|
|
@ -80,7 +82,21 @@ export const issuesApi = {
|
||||||
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
|
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
|
||||||
}),
|
}),
|
||||||
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
|
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`),
|
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||||
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,26 @@ vi.mock("@/plugins/slots", () => ({
|
||||||
|
|
||||||
describe("CommentThread", () => {
|
describe("CommentThread", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
let writeTextMock: ReturnType<typeof vi.fn>;
|
||||||
|
let execCommandMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z"));
|
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(() => {
|
afterEach(() => {
|
||||||
|
|
@ -234,4 +248,59 @@ describe("CommentThread", () => {
|
||||||
root.unmount();
|
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 }) {
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className={cn(
|
||||||
title="Copy as markdown"
|
"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={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
void copyTextWithFallback(text)
|
||||||
setCopied(true);
|
.then(() => setStatus("copied"))
|
||||||
setTimeout(() => setCopied(false), 2000);
|
.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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { act } from "react";
|
import { act, createRef, forwardRef, useImperativeHandle } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
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", () => ({
|
vi.mock("@assistant-ui/react", () => ({
|
||||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
ThreadPrimitive: {
|
ThreadPrimitive: {
|
||||||
|
|
@ -17,7 +25,7 @@ vi.mock("@assistant-ui/react", () => ({
|
||||||
<div data-testid="thread-viewport" className={className}>{children}</div>
|
<div data-testid="thread-viewport" className={className}>{children}</div>
|
||||||
),
|
),
|
||||||
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
Messages: () => <div data-testid="thread-messages" />,
|
Messages: () => threadMessagesMock(),
|
||||||
},
|
},
|
||||||
MessagePrimitive: {
|
MessagePrimitive: {
|
||||||
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
|
@ -48,22 +56,34 @@ vi.mock("./MarkdownBody", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./MarkdownEditor", () => ({
|
vi.mock("./MarkdownEditor", () => ({
|
||||||
MarkdownEditor: ({
|
MarkdownEditor: forwardRef(({
|
||||||
value = "",
|
value = "",
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}) => (
|
className?: string;
|
||||||
<textarea
|
contentClassName?: string;
|
||||||
aria-label="Issue chat editor"
|
}, ref) => {
|
||||||
placeholder={placeholder}
|
useImperativeHandle(ref, () => ({
|
||||||
value={value}
|
focus: markdownEditorFocusMock,
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
}));
|
||||||
/>
|
|
||||||
),
|
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", () => ({
|
vi.mock("./InlineEntitySelector", () => ({
|
||||||
|
|
@ -100,11 +120,14 @@ describe("IssueChatThread", () => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
threadMessagesMock.mockImplementation(() => <div data-testid="thread-messages" />);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
container.remove();
|
container.remove();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
markdownEditorFocusMock.mockReset();
|
||||||
|
threadMessagesMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
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", () => {
|
it("stores and restores the composer draft per issue key", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const root = createRoot(container);
|
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", () => {
|
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
||||||
expect(resolveAssistantMessageFoldedState({
|
expect(resolveAssistantMessageFoldedState({
|
||||||
messageId: "message-1",
|
messageId: "message-1",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,21 @@ import {
|
||||||
useMessage,
|
useMessage,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import type { ToolCallMessagePart } 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 { Link, useLocation } from "@/lib/router";
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
|
@ -65,7 +79,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 {
|
interface IssueChatMessageContext {
|
||||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||||
|
|
@ -80,6 +94,7 @@ interface IssueChatMessageContext {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
|
onImageClick?: (src: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||||
|
|
@ -144,6 +159,24 @@ interface CommentReassignment {
|
||||||
assigneeUserId: string | null;
|
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 {
|
interface IssueChatThreadProps {
|
||||||
comments: IssueChatComment[];
|
comments: IssueChatComment[];
|
||||||
feedbackVotes?: FeedbackVote[];
|
feedbackVotes?: FeedbackVote[];
|
||||||
|
|
@ -184,9 +217,151 @@ interface IssueChatThreadProps {
|
||||||
includeSucceededRunsWithoutOutput?: boolean;
|
includeSucceededRunsWithoutOutput?: boolean;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
interruptingQueuedRunId?: string | null;
|
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 DRAFT_DEBOUNCE_MS = 800;
|
||||||
|
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
||||||
|
|
||||||
function toIsoString(value: string | Date | null | undefined): string | null {
|
function toIsoString(value: string | Date | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
@ -246,8 +421,9 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||||
|
const { onImageClick } = useContext(IssueChatCtx);
|
||||||
return (
|
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}
|
{text}
|
||||||
</MarkdownBody>
|
</MarkdownBody>
|
||||||
);
|
);
|
||||||
|
|
@ -815,25 +991,26 @@ function IssueChatAssistantMessage() {
|
||||||
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
||||||
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||||
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
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 [folded, setFolded] = useState(isFoldable);
|
||||||
const previousMessageIdRef = useRef<string | null>(message.id);
|
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable });
|
||||||
const previousIsFoldableRef = useRef(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({
|
const nextFolded = resolveAssistantMessageFoldedState({
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
currentFolded: folded,
|
currentFolded: folded,
|
||||||
isFoldable,
|
isFoldable,
|
||||||
previousMessageId: previousMessageIdRef.current,
|
previousMessageId: prevFoldKey.messageId,
|
||||||
previousIsFoldable: previousIsFoldableRef.current,
|
previousIsFoldable: prevFoldKey.isFoldable,
|
||||||
});
|
});
|
||||||
previousMessageIdRef.current = message.id;
|
setPrevFoldKey({ messageId: message.id, isFoldable });
|
||||||
previousIsFoldableRef.current = isFoldable;
|
|
||||||
if (nextFolded !== folded) {
|
if (nextFolded !== folded) {
|
||||||
setFolded(nextFolded);
|
setFolded(nextFolded);
|
||||||
}
|
}
|
||||||
}, [folded, isFoldable, message.id]);
|
}
|
||||||
|
|
||||||
const handleVote = async (
|
const handleVote = async (
|
||||||
vote: FeedbackVoteValue,
|
vote: FeedbackVoteValue,
|
||||||
|
|
@ -896,8 +1073,15 @@ function IssueChatAssistantMessage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{message.content.length === 0 && waitingText ? (
|
{message.content.length === 0 && waitingText ? (
|
||||||
<div className="rounded-sm bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
|
||||||
{waitingText}
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{notices.length > 0 ? (
|
{notices.length > 0 ? (
|
||||||
|
|
@ -1350,7 +1534,7 @@ function IssueChatSystemMessage() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatComposer({
|
const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerProps>(function IssueChatComposer({
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onAttachImage,
|
onAttachImage,
|
||||||
draftKey,
|
draftKey,
|
||||||
|
|
@ -1362,19 +1546,7 @@ function IssueChatComposer({
|
||||||
agentMap,
|
agentMap,
|
||||||
composerDisabledReason = null,
|
composerDisabledReason = null,
|
||||||
issueStatus,
|
issueStatus,
|
||||||
}: {
|
}, forwardedRef) {
|
||||||
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;
|
|
||||||
}) {
|
|
||||||
const api = useAui();
|
const api = useAui();
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
|
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
|
||||||
|
|
@ -1384,6 +1556,7 @@ function IssueChatComposer({
|
||||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||||
|
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1409,6 +1582,16 @@ function IssueChatComposer({
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
}, [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() {
|
async function handleSubmit() {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed || submitting) return;
|
if (!trimmed || submitting) return;
|
||||||
|
|
@ -1477,7 +1660,11 @@ function IssueChatComposer({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<MarkdownEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={body}
|
value={body}
|
||||||
|
|
@ -1486,10 +1673,11 @@ function IssueChatComposer({
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
imageUploadHandler={onImageUpload}
|
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) ? (
|
{(onImageUpload || onAttachImage) ? (
|
||||||
<div className="mr-auto flex items-center gap-3">
|
<div className="mr-auto flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
|
|
@ -1566,7 +1754,7 @@ function IssueChatComposer({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function IssueChatThread({
|
export function IssueChatThread({
|
||||||
comments,
|
comments,
|
||||||
|
|
@ -1604,6 +1792,8 @@ export function IssueChatThread({
|
||||||
includeSucceededRunsWithoutOutput = false,
|
includeSucceededRunsWithoutOutput = false,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId = null,
|
interruptingQueuedRunId = null,
|
||||||
|
onImageClick,
|
||||||
|
composerRef,
|
||||||
}: IssueChatThreadProps) {
|
}: IssueChatThreadProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const hasScrolledRef = useRef(false);
|
const hasScrolledRef = useRef(false);
|
||||||
|
|
@ -1731,6 +1921,7 @@ export function IssueChatThread({
|
||||||
onVote,
|
onVote,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
|
onImageClick,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
feedbackVoteByTargetId,
|
feedbackVoteByTargetId,
|
||||||
|
|
@ -1741,6 +1932,7 @@ export function IssueChatThread({
|
||||||
onVote,
|
onVote,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
|
onImageClick,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1758,6 +1950,10 @@ export function IssueChatThread({
|
||||||
?? (variant === "embedded"
|
?? (variant === "embedded"
|
||||||
? "No run output yet."
|
? "No run output yet."
|
||||||
: "This issue conversation is empty. Start with a message below.");
|
: "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 (
|
return (
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
|
@ -1775,25 +1971,33 @@ export function IssueChatThread({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ThreadPrimitive.Root className="">
|
<IssueChatErrorBoundary
|
||||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
resetKey={errorBoundaryResetKey}
|
||||||
<ThreadPrimitive.Empty>
|
messages={messages}
|
||||||
<div className={cn(
|
emptyMessage={resolvedEmptyMessage}
|
||||||
"text-center text-sm text-muted-foreground",
|
variant={variant}
|
||||||
variant === "embedded"
|
>
|
||||||
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
<ThreadPrimitive.Root className="">
|
||||||
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
||||||
)}>
|
<ThreadPrimitive.Empty>
|
||||||
{resolvedEmptyMessage}
|
<div className={cn(
|
||||||
</div>
|
"text-center text-sm text-muted-foreground",
|
||||||
</ThreadPrimitive.Empty>
|
variant === "embedded"
|
||||||
<ThreadPrimitive.Messages components={components} />
|
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||||||
<div ref={bottomAnchorRef} />
|
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
||||||
</ThreadPrimitive.Viewport>
|
)}>
|
||||||
</ThreadPrimitive.Root>
|
{resolvedEmptyMessage}
|
||||||
|
</div>
|
||||||
|
</ThreadPrimitive.Empty>
|
||||||
|
<ThreadPrimitive.Messages components={components} />
|
||||||
|
<div ref={bottomAnchorRef} />
|
||||||
|
</ThreadPrimitive.Viewport>
|
||||||
|
</ThreadPrimitive.Root>
|
||||||
|
</IssueChatErrorBoundary>
|
||||||
|
|
||||||
{showComposer ? (
|
{showComposer ? (
|
||||||
<IssueChatComposer
|
<IssueChatComposer
|
||||||
|
ref={composerRef}
|
||||||
onImageUpload={imageUploadHandler}
|
onImageUpload={imageUploadHandler}
|
||||||
onAttachImage={onAttachImage}
|
onAttachImage={onAttachImage}
|
||||||
draftKey={draftKey}
|
draftKey={draftKey}
|
||||||
|
|
|
||||||
343
ui/src/components/IssueColumns.tsx
Normal file
343
ui/src/components/IssueColumns.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { Columns3 } from "lucide-react";
|
||||||
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
|
import type { InboxIssueColumn } from "../lib/inbox";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
|
import { Identity } from "./Identity";
|
||||||
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
||||||
|
export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
|
||||||
|
|
||||||
|
const issueColumnLabels: Record<InboxIssueColumn, string> = {
|
||||||
|
status: "Status",
|
||||||
|
id: "ID",
|
||||||
|
assignee: "Assignee",
|
||||||
|
project: "Project",
|
||||||
|
workspace: "Workspace",
|
||||||
|
parent: "Parent issue",
|
||||||
|
labels: "Tags",
|
||||||
|
updated: "Last updated",
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
||||||
|
status: "Issue state chip on the left edge.",
|
||||||
|
id: "Ticket identifier like PAP-1009.",
|
||||||
|
assignee: "Assigned agent or board user.",
|
||||||
|
project: "Linked project pill with its color.",
|
||||||
|
workspace: "Execution or project workspace used for the issue.",
|
||||||
|
parent: "Parent issue identifier and title.",
|
||||||
|
labels: "Issue labels and tags.",
|
||||||
|
updated: "Latest visible activity time.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function issueActivityText(issue: Issue): string {
|
||||||
|
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||||
|
return columns
|
||||||
|
.map((column) => {
|
||||||
|
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||||
|
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||||
|
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||||
|
if (column === "parent") return "minmax(5rem, 7rem)";
|
||||||
|
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||||
|
return "minmax(4rem, 5.5rem)";
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueColumnPicker({
|
||||||
|
availableColumns,
|
||||||
|
visibleColumnSet,
|
||||||
|
onToggleColumn,
|
||||||
|
onResetColumns,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
availableColumns: InboxIssueColumn[];
|
||||||
|
visibleColumnSet: ReadonlySet<InboxIssueColumn>;
|
||||||
|
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
|
||||||
|
onResetColumns: () => void;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"
|
||||||
|
>
|
||||||
|
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Columns
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
||||||
|
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
Desktop issue rows
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{availableColumns.map((column) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column}
|
||||||
|
checked={visibleColumnSet.has(column)}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
onCheckedChange={(checked) => onToggleColumn(column, checked === true)}
|
||||||
|
className="items-start rounded-lg px-3 py-2.5 pl-8"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{issueColumnLabels[column]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{issueColumnDescriptions[column]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={onResetColumns}
|
||||||
|
className="rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
Reset defaults
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxIssueMetaLeading({
|
||||||
|
issue,
|
||||||
|
isLive,
|
||||||
|
showStatus = true,
|
||||||
|
showIdentifier = true,
|
||||||
|
statusSlot,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
isLive: boolean;
|
||||||
|
showStatus?: boolean;
|
||||||
|
showIdentifier?: boolean;
|
||||||
|
statusSlot?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showStatus ? (
|
||||||
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
|
{statusSlot ?? <StatusIcon status={issue.status} />}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{showIdentifier ? (
|
||||||
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isLive && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
||||||
|
"bg-blue-500/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-2 w-2 rounded-full",
|
||||||
|
"bg-blue-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"hidden text-[11px] font-medium sm:inline",
|
||||||
|
"text-blue-600 dark:text-blue-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxIssueTrailingColumns({
|
||||||
|
issue,
|
||||||
|
columns,
|
||||||
|
projectName,
|
||||||
|
projectColor,
|
||||||
|
workspaceName,
|
||||||
|
assigneeName,
|
||||||
|
currentUserId,
|
||||||
|
parentIdentifier,
|
||||||
|
parentTitle,
|
||||||
|
assigneeContent,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
columns: InboxIssueColumn[];
|
||||||
|
projectName: string | null;
|
||||||
|
projectColor: string | null;
|
||||||
|
workspaceName: string | null;
|
||||||
|
assigneeName: string | null;
|
||||||
|
currentUserId: string | null;
|
||||||
|
parentIdentifier: string | null;
|
||||||
|
parentTitle: string | null;
|
||||||
|
assigneeContent?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||||
|
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid items-center gap-2"
|
||||||
|
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
|
||||||
|
>
|
||||||
|
{columns.map((column) => {
|
||||||
|
if (column === "assignee") {
|
||||||
|
if (assigneeContent) {
|
||||||
|
return <span key={column} className="min-w-0">{assigneeContent}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issue.assigneeAgentId) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||||
|
<Identity
|
||||||
|
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
|
||||||
|
size="sm"
|
||||||
|
className="min-w-0"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issue.assigneeUserId) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||||
|
{userLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
Unassigned
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "project") {
|
||||||
|
if (projectName) {
|
||||||
|
const accentColor = projectColor ?? "#64748b";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={column}
|
||||||
|
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
|
||||||
|
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{projectName}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
No project
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "labels") {
|
||||||
|
if ((issue.labels ?? []).length > 0) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
|
||||||
|
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.id}
|
||||||
|
className="inline-flex min-w-0 max-w-full items-center font-medium"
|
||||||
|
style={{
|
||||||
|
color: pickTextColorForPillBg(label.color, 0.12),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(issue.labels ?? []).length > 2 ? (
|
||||||
|
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
||||||
|
+{(issue.labels ?? []).length - 2}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "workspace") {
|
||||||
|
if (!workspaceName) {
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
{workspaceName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "parent") {
|
||||||
|
if (!issue.parentId) {
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
|
||||||
|
{parentIdentifier ? (
|
||||||
|
<span className="font-mono">{parentIdentifier}</span>
|
||||||
|
) : (
|
||||||
|
<span className="italic">Sub-issue</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "updated") {
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
||||||
|
{activityText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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>) {
|
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -201,4 +226,119 @@ describe("IssueProperties", () => {
|
||||||
|
|
||||||
act(() => root.unmount());
|
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
|
const approverTrigger = approverValues.length > 0
|
||||||
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||||
: <span className="text-sm text-muted-foreground">None</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 = (() => {
|
const currentExecutionLabel = (() => {
|
||||||
if (!issue.executionState?.currentStageType) return null;
|
if (!issue.executionState?.currentStageType) return null;
|
||||||
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
|
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
|
||||||
|
|
@ -846,15 +866,13 @@ export function IssueProperties({
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<span className="text-sm text-muted-foreground">None</span>
|
|
||||||
)}
|
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
||||||
<PropertyRow label="Sub-issues">
|
<PropertyRow label="Sub-issues">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{childIssues.length > 0 ? (
|
{childIssues.length > 0
|
||||||
childIssues.map((child) => (
|
? childIssues.map((child) => (
|
||||||
<Link
|
<Link
|
||||||
key={child.id}
|
key={child.id}
|
||||||
to={`/issues/${child.identifier ?? child.id}`}
|
to={`/issues/${child.identifier ?? child.id}`}
|
||||||
|
|
@ -863,9 +881,7 @@ export function IssueProperties({
|
||||||
{child.identifier ?? child.title}
|
{child.identifier ?? child.title}
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
) : (
|
: null}
|
||||||
<span className="text-sm text-muted-foreground">None</span>
|
|
||||||
)}
|
|
||||||
{onAddSubIssue ? (
|
{onAddSubIssue ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -896,6 +912,7 @@ export function IssueProperties({
|
||||||
() => updateExecutionPolicy([], approverValues),
|
() => updateExecutionPolicy([], approverValues),
|
||||||
)}
|
)}
|
||||||
</PropertyPicker>
|
</PropertyPicker>
|
||||||
|
{nextRunnableExecutionStage === "review" && reviewerValues.length > 0 ? runExecutionButton("review") : null}
|
||||||
|
|
||||||
<PropertyPicker
|
<PropertyPicker
|
||||||
inline={inline}
|
inline={inline}
|
||||||
|
|
@ -914,6 +931,7 @@ export function IssueProperties({
|
||||||
() => updateExecutionPolicy(reviewerValues, []),
|
() => updateExecutionPolicy(reviewerValues, []),
|
||||||
)}
|
)}
|
||||||
</PropertyPicker>
|
</PropertyPicker>
|
||||||
|
{nextRunnableExecutionStage === "approval" && approverValues.length > 0 ? runExecutionButton("approval") : null}
|
||||||
|
|
||||||
{currentExecutionLabel && (
|
{currentExecutionLabel && (
|
||||||
<PropertyRow label="Execution">
|
<PropertyRow label="Execution">
|
||||||
|
|
|
||||||
169
ui/src/components/IssueWorkspaceCard.test.tsx
Normal file
169
ui/src/components/IssueWorkspaceCard.test.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { Issue, Project } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||||
|
|
||||||
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
|
getExperimental: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: mockInstanceSettingsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/execution-workspaces", () => ({
|
||||||
|
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
|
return {
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Issue workspace",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
issueNumber: 1,
|
||||||
|
identifier: "PAP-1",
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: "shared_workspace",
|
||||||
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProject(): Project {
|
||||||
|
return {
|
||||||
|
id: "project-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
urlKey: "project-1",
|
||||||
|
goalId: null,
|
||||||
|
goalIds: [],
|
||||||
|
goals: [],
|
||||||
|
name: "Project 1",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: "#22c55e",
|
||||||
|
env: null,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
allowIssueOverride: true,
|
||||||
|
},
|
||||||
|
codebase: {
|
||||||
|
workspaceId: null,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
repoName: null,
|
||||||
|
localFolder: null,
|
||||||
|
managedFolder: "/tmp/project-1",
|
||||||
|
effectiveLocalFolder: "/tmp/project-1",
|
||||||
|
origin: "managed_checkout",
|
||||||
|
},
|
||||||
|
workspaces: [],
|
||||||
|
primaryWorkspace: null,
|
||||||
|
createdAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(container: HTMLDivElement) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<IssueWorkspaceCard issue={createIssue()} project={createProject()} onUpdate={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IssueWorkspaceCard", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
mockExecutionWorkspacesApi.list.mockReset();
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a stable skeleton while workspace settings are still loading", async () => {
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
const root = renderCard(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="issue-workspace-card-skeleton"]')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
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 */
|
/* Main component */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
@ -195,14 +215,15 @@ export function IssueWorkspaceCard({
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
const [editing, setEditing] = useState(initialEditing);
|
const [editing, setEditing] = useState(initialEditing);
|
||||||
|
|
||||||
const { data: experimentalSettings } = useQuery({
|
const { data: experimentalSettings, isLoading: experimentalSettingsLoading } = useQuery({
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectWorkspacePolicyEnabled = Boolean(project?.executionWorkspacePolicy?.enabled);
|
||||||
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
||||||
&& Boolean(project?.executionWorkspacePolicy?.enabled);
|
&& projectWorkspacePolicyEnabled;
|
||||||
|
|
||||||
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
|
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
|
||||||
|
|
||||||
|
|
@ -314,6 +335,10 @@ export function IssueWorkspaceCard({
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}, [currentSelection, issue.executionWorkspaceId]);
|
}, [currentSelection, issue.executionWorkspaceId]);
|
||||||
|
|
||||||
|
if (project && projectWorkspacePolicyEnabled && experimentalSettingsLoading) {
|
||||||
|
return <IssueWorkspaceCardSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!policyEnabled || !project) return null;
|
if (!policyEnabled || !project) return null;
|
||||||
|
|
||||||
const showEditingControls = livePreview || editing;
|
const showEditingControls = livePreview || editing;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||||
getSession: vi.fn(),
|
getSession: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
|
getExperimental: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../context/CompanyContext", () => ({
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
useCompany: () => companyState,
|
useCompany: () => companyState,
|
||||||
}));
|
}));
|
||||||
|
|
@ -41,8 +49,30 @@ vi.mock("../api/auth", () => ({
|
||||||
authApi: mockAuthApi,
|
authApi: mockAuthApi,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/execution-workspaces", () => ({
|
||||||
|
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: mockInstanceSettingsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./IssueRow", () => ({
|
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", () => ({
|
vi.mock("./KanbanBoard", () => ({
|
||||||
|
|
@ -90,6 +120,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
labelIds: [],
|
labelIds: [],
|
||||||
myLastTouchAt: null,
|
myLastTouchAt: null,
|
||||||
lastExternalCommentAt: null,
|
lastExternalCommentAt: null,
|
||||||
|
lastActivityAt: null,
|
||||||
isUnreadForMe: false,
|
isUnreadForMe: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
@ -148,11 +179,18 @@ describe("IssuesList", () => {
|
||||||
mockIssuesApi.list.mockReset();
|
mockIssuesApi.list.mockReset();
|
||||||
mockIssuesApi.listLabels.mockReset();
|
mockIssuesApi.listLabels.mockReset();
|
||||||
mockAuthApi.getSession.mockReset();
|
mockAuthApi.getSession.mockReset();
|
||||||
|
mockExecutionWorkspacesApi.list.mockReset();
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||||
|
mockIssuesApi.list.mockResolvedValue([]);
|
||||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||||
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,4 +222,89 @@ describe("IssuesList", () => {
|
||||||
root.unmount();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 { useQuery } from "@tanstack/react-query";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import { groupBy } from "../lib/groupBy";
|
import { groupBy } from "../lib/groupBy";
|
||||||
import { formatDate, cn } from "../lib/utils";
|
import {
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
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 { StatusIcon } from "./StatusIcon";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { EmptyState } from "./EmptyState";
|
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 { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||||
import { KanbanBoard } from "./KanbanBoard";
|
import { KanbanBoard } from "./KanbanBoard";
|
||||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue, Project } from "@paperclipai/shared";
|
||||||
|
|
||||||
/* ── Helpers ── */
|
/* ── Helpers ── */
|
||||||
|
|
||||||
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||||
const priorityOrder = ["critical", "high", "medium", "low"];
|
const priorityOrder = ["critical", "high", "medium", "low"];
|
||||||
|
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
@ -45,7 +62,7 @@ export type IssueViewState = {
|
||||||
projects: string[];
|
projects: string[];
|
||||||
sortField: "status" | "priority" | "title" | "created" | "updated";
|
sortField: "status" | "priority" | "title" | "created" | "updated";
|
||||||
sortDir: "asc" | "desc";
|
sortDir: "asc" | "desc";
|
||||||
groupBy: "status" | "priority" | "assignee" | "none";
|
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||||
viewMode: "list" | "board";
|
viewMode: "list" | "board";
|
||||||
collapsedGroups: string[];
|
collapsedGroups: string[];
|
||||||
collapsedParents: string[];
|
collapsedParents: string[];
|
||||||
|
|
@ -152,10 +169,7 @@ interface Agent {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectOption {
|
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IssuesListProps {
|
interface IssuesListProps {
|
||||||
issues: Issue[];
|
issues: Issue[];
|
||||||
|
|
@ -176,6 +190,50 @@ interface IssuesListProps {
|
||||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
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({
|
export function IssuesList({
|
||||||
issues,
|
issues,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -198,7 +256,13 @@ export function IssuesList({
|
||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
queryFn: () => authApi.getSession(),
|
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 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.
|
// Scope the storage key per company so folding/view state is independent across companies.
|
||||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||||
|
|
@ -212,6 +276,7 @@ export function IssuesList({
|
||||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||||
|
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||||
|
|
||||||
|
|
@ -259,12 +324,103 @@ export function IssuesList({
|
||||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||||
placeholderData: (previousData) => previousData,
|
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) => {
|
const agentName = useCallback((id: string | null) => {
|
||||||
if (!id || !agents) return null;
|
if (!id || !agents) return null;
|
||||||
return agents.find((a) => a.id === id)?.name ?? null;
|
return agents.find((a) => a.id === id)?.name ?? null;
|
||||||
}, [agents]);
|
}, [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 filtered = useMemo(() => {
|
||||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||||
|
|
@ -295,6 +451,36 @@ export function IssuesList({
|
||||||
.filter((p) => groups[p]?.length)
|
.filter((p) => groups[p]?.length)
|
||||||
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
.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
|
// assignee
|
||||||
const groups = groupBy(
|
const groups = groupBy(
|
||||||
filtered,
|
filtered,
|
||||||
|
|
@ -310,7 +496,7 @@ export function IssuesList({
|
||||||
: (agentName(key) ?? key.slice(0, 8)),
|
: (agentName(key) ?? key.slice(0, 8)),
|
||||||
items: groups[key]!,
|
items: groups[key]!,
|
||||||
}));
|
}));
|
||||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
|
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||||
|
|
||||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||||
const defaults: Record<string, string> = {};
|
const defaults: Record<string, string> = {};
|
||||||
|
|
@ -322,10 +508,27 @@ export function IssuesList({
|
||||||
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||||
else defaults.assigneeAgentId = groupKey;
|
else defaults.assigneeAgentId = groupKey;
|
||||||
}
|
}
|
||||||
|
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
||||||
|
defaults.parentId = groupKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return defaults;
|
return defaults;
|
||||||
}, [projectId, viewState.groupBy]);
|
}, [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) => {
|
const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
||||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
||||||
setAssigneePickerIssueId(null);
|
setAssigneePickerIssueId(null);
|
||||||
|
|
@ -342,19 +545,13 @@ export function IssuesList({
|
||||||
<Plus className="h-4 w-4 sm:mr-1" />
|
<Plus className="h-4 w-4 sm:mr-1" />
|
||||||
<span className="hidden sm:inline">New Issue</span>
|
<span className="hidden sm:inline">New Issue</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative w-48 sm:w-64 md:w-80">
|
<IssueSearchInput
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
value={issueSearch}
|
||||||
<Input
|
onDebouncedChange={(nextSearch) => {
|
||||||
value={issueSearch}
|
setIssueSearch(nextSearch);
|
||||||
onChange={(e) => {
|
onSearchChange?.(nextSearch);
|
||||||
setIssueSearch(e.target.value);
|
}}
|
||||||
onSearchChange?.(e.target.value);
|
/>
|
||||||
}}
|
|
||||||
placeholder="Search issues..."
|
|
||||||
className="pl-7 text-xs sm:text-sm"
|
|
||||||
aria-label="Search issues"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||||
|
|
@ -376,6 +573,14 @@ export function IssuesList({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<IssueColumnPicker
|
||||||
|
availableColumns={availableIssueColumns}
|
||||||
|
visibleColumnSet={visibleIssueColumnSet}
|
||||||
|
onToggleColumn={toggleIssueColumn}
|
||||||
|
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||||
|
title="Choose which issue columns stay visible"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -605,6 +810,8 @@ export function IssuesList({
|
||||||
["status", "Status"],
|
["status", "Status"],
|
||||||
["priority", "Priority"],
|
["priority", "Priority"],
|
||||||
["assignee", "Assignee"],
|
["assignee", "Assignee"],
|
||||||
|
["workspace", "Workspace"],
|
||||||
|
["parent", "Parent Issue"],
|
||||||
["none", "None"],
|
["none", "None"],
|
||||||
] as const).map(([value, label]) => (
|
] as const).map(([value, label]) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -684,6 +891,8 @@ export function IssuesList({
|
||||||
const hasChildren = children.length > 0;
|
const hasChildren = children.length > 0;
|
||||||
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
||||||
const isExpanded = !viewState.collapsedParents.includes(issue.id);
|
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 }) => {
|
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -728,154 +937,139 @@ export function IssuesList({
|
||||||
) : (
|
) : (
|
||||||
<span className="hidden w-3.5 shrink-0 sm:block" />
|
<span className="hidden w-3.5 shrink-0 sm:block" />
|
||||||
)}
|
)}
|
||||||
<span
|
<InboxIssueMetaLeading
|
||||||
className="hidden shrink-0 sm:inline-flex"
|
issue={issue}
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
isLive={liveIssueIds?.has(issue.id) === true}
|
||||||
>
|
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||||
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||||
</span>
|
statusSlot={(
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||||
</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" />
|
|
||||||
</span>
|
</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={(
|
desktopTrailing={(
|
||||||
<>
|
visibleTrailingIssueColumns.length > 0 ? (
|
||||||
{(issue.labels ?? []).length > 0 && (
|
<InboxIssueTrailingColumns
|
||||||
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
|
issue={issue}
|
||||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
columns={visibleTrailingIssueColumns}
|
||||||
<span
|
projectName={issueProject?.name ?? null}
|
||||||
key={label.id}
|
projectColor={issueProject?.color ?? null}
|
||||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
workspaceName={resolveIssueWorkspaceName(issue, {
|
||||||
style={{
|
executionWorkspaceById,
|
||||||
borderColor: label.color,
|
projectWorkspaceById,
|
||||||
color: pickTextColorForPillBg(label.color, 0.12),
|
defaultProjectWorkspaceIdByProjectId,
|
||||||
backgroundColor: `${label.color}1f`,
|
})}
|
||||||
}}
|
assigneeName={agentName(issue.assigneeAgentId)}
|
||||||
>
|
currentUserId={currentUserId}
|
||||||
{label.name}
|
parentIdentifier={parentIssue?.identifier ?? null}
|
||||||
</span>
|
parentTitle={parentIssue?.title ?? null}
|
||||||
))}
|
assigneeContent={(
|
||||||
{(issue.labels ?? []).length > 3 && (
|
<Popover
|
||||||
<span className="text-[10px] text-muted-foreground">
|
open={assigneePickerIssueId === issue.id}
|
||||||
+{(issue.labels ?? []).length - 3}
|
onOpenChange={(open) => {
|
||||||
</span>
|
setAssigneePickerIssueId(open ? issue.id : null);
|
||||||
)}
|
if (!open) setAssigneeSearch("");
|
||||||
</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(); }}
|
|
||||||
>
|
>
|
||||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
<PopoverTrigger asChild>
|
||||||
<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 && (
|
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||||
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" />
|
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||||
<span>Me</span>
|
<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>
|
</button>
|
||||||
)}
|
</PopoverTrigger>
|
||||||
{(agents ?? [])
|
<PopoverContent
|
||||||
.filter((agent) => {
|
className="w-56 p-1"
|
||||||
if (!assigneeSearch.trim()) return true;
|
align="end"
|
||||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
onClick={(e) => e.stopPropagation()}
|
||||||
})
|
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||||
.map((agent) => (
|
>
|
||||||
|
<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
|
<button
|
||||||
key={agent.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||||
issue.assigneeAgentId === agent.id && "bg-accent",
|
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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>
|
</button>
|
||||||
))}
|
{currentUserId && (
|
||||||
</div>
|
<button
|
||||||
</PopoverContent>
|
className={cn(
|
||||||
</Popover>
|
"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))}
|
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
101
ui/src/components/KeyboardShortcutsCheatsheet.tsx
Normal file
101
ui/src/components/KeyboardShortcutsCheatsheet.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface ShortcutEntry {
|
||||||
|
keys: string[];
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutSection {
|
||||||
|
title: string;
|
||||||
|
shortcuts: ShortcutEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: ShortcutSection[] = [
|
||||||
|
{
|
||||||
|
title: "Inbox",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["j"], label: "Move down" },
|
||||||
|
{ keys: ["k"], label: "Move up" },
|
||||||
|
{ keys: ["Enter"], label: "Open selected item" },
|
||||||
|
{ keys: ["a"], label: "Archive item" },
|
||||||
|
{ keys: ["y"], label: "Archive item" },
|
||||||
|
{ keys: ["r"], label: "Mark as read" },
|
||||||
|
{ keys: ["U"], label: "Mark as unread" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Issue detail",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["y"], label: "Quick-archive back to inbox" },
|
||||||
|
{ keys: ["g", "i"], label: "Go to inbox" },
|
||||||
|
{ keys: ["g", "c"], label: "Focus comment composer" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Global",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["c"], label: "New issue" },
|
||||||
|
{ keys: ["["], label: "Toggle sidebar" },
|
||||||
|
{ keys: ["]"], label: "Toggle panel" },
|
||||||
|
{ keys: ["?"], label: "Show keyboard shortcuts" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function KeyCap({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs font-medium text-foreground shadow-[0_1px_0_1px_hsl(var(--border))]">
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardShortcutsCheatsheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden" showCloseButton={false}>
|
||||||
|
<DialogHeader className="px-5 pt-5 pb-3">
|
||||||
|
<DialogTitle className="text-base">Keyboard shortcuts</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="divide-y divide-border border-t border-border">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div key={section.title} className="px-5 py-3">
|
||||||
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{section.shortcuts.map((shortcut) => (
|
||||||
|
<div
|
||||||
|
key={shortcut.label + shortcut.keys.join()}
|
||||||
|
className="flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground/90">{shortcut.label}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{shortcut.keys.map((key, i) => (
|
||||||
|
<span key={key} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
|
||||||
|
<KeyCap>{key}</KeyCap>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border px-5 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Press <KeyCap>Esc</KeyCap> to close · Shortcuts are disabled in text fields
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import { NewIssueDialog } from "./NewIssueDialog";
|
||||||
import { NewProjectDialog } from "./NewProjectDialog";
|
import { NewProjectDialog } from "./NewProjectDialog";
|
||||||
import { NewGoalDialog } from "./NewGoalDialog";
|
import { NewGoalDialog } from "./NewGoalDialog";
|
||||||
import { NewAgentDialog } from "./NewAgentDialog";
|
import { NewAgentDialog } from "./NewAgentDialog";
|
||||||
|
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
||||||
import { ToastViewport } from "./ToastViewport";
|
import { ToastViewport } from "./ToastViewport";
|
||||||
import { MobileBottomNav } from "./MobileBottomNav";
|
import { MobileBottomNav } from "./MobileBottomNav";
|
||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
|
|
@ -32,6 +33,7 @@ import {
|
||||||
normalizeRememberedInstanceSettingsPath,
|
normalizeRememberedInstanceSettingsPath,
|
||||||
} from "../lib/instance-settings";
|
} from "../lib/instance-settings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { NotFoundPage } from "../pages/NotFound";
|
import { NotFoundPage } from "../pages/NotFound";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -69,6 +71,7 @@ export function Layout() {
|
||||||
const lastMainScrollTop = useRef(0);
|
const lastMainScrollTop = useRef(0);
|
||||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||||
const matchedCompany = useMemo(() => {
|
const matchedCompany = useMemo(() => {
|
||||||
if (!companyPrefix) return null;
|
if (!companyPrefix) return null;
|
||||||
|
|
@ -151,6 +154,7 @@ export function Layout() {
|
||||||
onNewIssue: () => openNewIssue(),
|
onNewIssue: () => openNewIssue(),
|
||||||
onToggleSidebar: toggleSidebar,
|
onToggleSidebar: toggleSidebar,
|
||||||
onTogglePanel: togglePanel,
|
onTogglePanel: togglePanel,
|
||||||
|
onShowShortcuts: () => setShortcutsOpen(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -265,6 +269,12 @@ export function Layout() {
|
||||||
}
|
}
|
||||||
}, [location.hash, location.pathname, location.search]);
|
}, [location.hash, location.pathname, location.search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const mainContent = document.getElementById("main-content");
|
||||||
|
return scheduleMainContentFocus(mainContent);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -420,7 +430,7 @@ export function Layout() {
|
||||||
id="main-content"
|
id="main-content"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn(
|
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",
|
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -443,6 +453,7 @@ export function Layout() {
|
||||||
<NewProjectDialog />
|
<NewProjectDialog />
|
||||||
<NewGoalDialog />
|
<NewGoalDialog />
|
||||||
<NewAgentDialog />
|
<NewAgentDialog />
|
||||||
|
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||||
<ToastViewport />
|
<ToastViewport />
|
||||||
</div>
|
</div>
|
||||||
</GeneralSettingsProvider>
|
</GeneralSettingsProvider>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ interface MarkdownBodyProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||||
resolveImageSrc?: (src: string) => string | null;
|
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;
|
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 { theme } = useTheme();
|
||||||
const components: Components = {
|
const components: Components = {
|
||||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
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 }) => {
|
components.img = ({ node: _node, src, alt, ...imgProps }) => {
|
||||||
const resolved = src ? resolveImageSrc(src) : null;
|
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
||||||
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
|
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;
|
return map;
|
||||||
}, [mentions]);
|
}, [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[]>(() => {
|
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
|
||||||
if (!mentionState) return [];
|
if (!mentionState) return [];
|
||||||
const q = mentionState.query.trim().toLowerCase();
|
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);
|
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
||||||
}, [mentionState, mentions, slashCommands]);
|
}, [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, () => ({
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
import { getWorktreeUiBranding } from "../lib/worktree-branding";
|
import { getWorktreeUiBranding } from "../lib/worktree-branding";
|
||||||
|
|
||||||
export function WorktreeBanner() {
|
export function WorktreeBanner() {
|
||||||
const branding = getWorktreeUiBranding();
|
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;
|
if (!branding) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -18,7 +29,14 @@ export function WorktreeBanner() {
|
||||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
<span className="shrink-0 opacity-70">Worktree</span>
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
118
ui/src/components/transcript/useLiveRunTranscripts.test.tsx
Normal file
118
ui/src/components/transcript/useLiveRunTranscripts.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
|
||||||
|
|
||||||
|
const { useQueryMock, logMock } = vi.hoisted(() => ({
|
||||||
|
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
|
||||||
|
logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
|
useQuery: useQueryMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: {
|
||||||
|
getGeneral: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api/heartbeats", () => ({
|
||||||
|
heartbeatsApi: {
|
||||||
|
log: logMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters", () => ({
|
||||||
|
buildTranscript: (chunks: unknown[]) => chunks,
|
||||||
|
getUIAdapter: () => null,
|
||||||
|
onAdapterChange: () => () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
static readonly CONNECTING = 0;
|
||||||
|
static readonly OPEN = 1;
|
||||||
|
static readonly CLOSING = 2;
|
||||||
|
static readonly CLOSED = 3;
|
||||||
|
static instances: FakeWebSocket[] = [];
|
||||||
|
|
||||||
|
readonly url: string;
|
||||||
|
readyState = FakeWebSocket.CONNECTING;
|
||||||
|
onopen: ((event: Event) => void) | null = null;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
onerror: ((event: Event) => void) | null = null;
|
||||||
|
onclose: ((event: CloseEvent) => void) | null = null;
|
||||||
|
closeCalls: Array<{ code?: number; reason?: string }> = [];
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code?: number, reason?: string) {
|
||||||
|
this.closeCalls.push({ code, reason });
|
||||||
|
this.readyState = FakeWebSocket.CLOSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerOpen() {
|
||||||
|
this.readyState = FakeWebSocket.OPEN;
|
||||||
|
this.onopen?.(new Event("open"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("useLiveRunTranscripts", () => {
|
||||||
|
const OriginalWebSocket = globalThis.WebSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeWebSocket.instances = [];
|
||||||
|
useQueryMock.mockClear();
|
||||||
|
logMock.mockClear();
|
||||||
|
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.WebSocket = OriginalWebSocket;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for a connecting socket to open before closing it during cleanup", async () => {
|
||||||
|
function Harness() {
|
||||||
|
useLiveRunTranscripts({
|
||||||
|
companyId: "company-1",
|
||||||
|
runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }],
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
const socket = FakeWebSocket.instances[0];
|
||||||
|
expect(socket.closeCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(socket.closeCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.triggerOpen();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]);
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -281,7 +281,16 @@ export function useLiveRunTranscripts({
|
||||||
socket.onmessage = null;
|
socket.onmessage = null;
|
||||||
socket.onerror = null;
|
socket.onerror = null;
|
||||||
socket.onclose = 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]);
|
}, [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,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}, ref) {
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
ref={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-size={size}
|
data-size={size}
|
||||||
|
|
@ -59,6 +63,8 @@ function Button({
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
||||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
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 invalidations: unknown[] = [];
|
||||||
const queryClient = {
|
const queryClient = {
|
||||||
invalidateQueries: (input: unknown) => {
|
invalidateQueries: (input: unknown) => {
|
||||||
|
|
@ -20,6 +20,7 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
{
|
{
|
||||||
entityType: "issue",
|
entityType: "issue",
|
||||||
entityId: "issue-1",
|
entityId: "issue-1",
|
||||||
|
action: "issue.updated",
|
||||||
details: null,
|
details: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -33,6 +34,58 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
expect(invalidations).toContainEqual({
|
expect(invalidations).toContainEqual({
|
||||||
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
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 entityType = readString(payload.entityType);
|
||||||
const entityId = readString(payload.entityId);
|
const entityId = readString(payload.entityId);
|
||||||
|
const action = readString(payload.action);
|
||||||
|
|
||||||
if (entityType === "issue") {
|
if (entityType === "issue") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
|
@ -498,14 +499,10 @@ function invalidateActivityQueries(
|
||||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||||
for (const ref of issueRefs) {
|
for (const ref of issueRefs) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
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.activity(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
if (action === "issue.comment_added") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(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) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
58
ui/src/hooks/useKeyboardShortcuts.test.tsx
Normal file
58
ui/src/hooks/useKeyboardShortcuts.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function TestHarness({
|
||||||
|
onNewIssue,
|
||||||
|
}: {
|
||||||
|
onNewIssue: () => void;
|
||||||
|
}) {
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
enabled: true,
|
||||||
|
onNewIssue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>keyboard shortcuts test</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useKeyboardShortcuts", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores events already claimed by another handler", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onNewIssue = vi.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<TestHarness onNewIssue={onNewIssue} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "c",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onNewIssue).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ interface ShortcutHandlers {
|
||||||
onNewIssue?: () => void;
|
onNewIssue?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
onTogglePanel?: () => void;
|
onTogglePanel?: () => void;
|
||||||
|
onShowShortcuts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcuts({
|
export function useKeyboardShortcuts({
|
||||||
|
|
@ -13,16 +14,28 @@ export function useKeyboardShortcuts({
|
||||||
onNewIssue,
|
onNewIssue,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onTogglePanel,
|
onTogglePanel,
|
||||||
|
onShowShortcuts,
|
||||||
}: ShortcutHandlers) {
|
}: ShortcutHandlers) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't fire shortcuts when typing in inputs
|
// Don't fire shortcuts when typing in inputs
|
||||||
if (isKeyboardShortcutTextInputTarget(e.target)) {
|
if (isKeyboardShortcutTextInputTarget(e.target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ? → Show keyboard shortcuts cheatsheet
|
||||||
|
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onShowShortcuts?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// C → New Issue
|
// C → New Issue
|
||||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -44,5 +57,5 @@ export function useKeyboardShortcuts({
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]);
|
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel, onShowShortcuts]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
@keyframes shimmer-text-slide {
|
||||||
0% { background-position: 200% center; }
|
0% { background-position: 100% center; }
|
||||||
100% { background-position: -200% center; }
|
60% { background-position: 0% center; }
|
||||||
|
100% { background-position: 0% center; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.shimmer-text {
|
.shimmer-text {
|
||||||
--shimmer-base: hsl(var(--foreground) / 0.75);
|
--shimmer-base: var(--foreground);
|
||||||
--shimmer-highlight: hsl(var(--foreground) / 0.3);
|
--shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
110deg,
|
90deg,
|
||||||
var(--shimmer-base) 35%,
|
var(--shimmer-base) 0%,
|
||||||
|
var(--shimmer-base) 40%,
|
||||||
var(--shimmer-highlight) 50%,
|
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;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,23 @@ import {
|
||||||
describe("company routes", () => {
|
describe("company routes", () => {
|
||||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
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")).toBe(true);
|
||||||
|
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/issues")).toBe(true);
|
||||||
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
||||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
||||||
"/PAP/execution-workspaces/workspace-123",
|
"/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", () => {
|
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
||||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
||||||
"/execution-workspaces/workspace-123",
|
"/execution-workspaces/workspace-123",
|
||||||
);
|
);
|
||||||
|
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/configuration")).toBe(
|
||||||
|
"/execution-workspaces/workspace-123/configuration",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
normalizeInboxIssueColumns,
|
normalizeInboxIssueColumns,
|
||||||
RECENT_ISSUES_LIMIT,
|
RECENT_ISSUES_LIMIT,
|
||||||
|
resolveInboxNestingEnabled,
|
||||||
resolveIssueWorkspaceName,
|
resolveIssueWorkspaceName,
|
||||||
resolveInboxSelectionIndex,
|
resolveInboxSelectionIndex,
|
||||||
saveInboxIssueColumns,
|
saveInboxIssueColumns,
|
||||||
|
|
@ -552,6 +553,19 @@ describe("inbox helpers", () => {
|
||||||
expect(loadLastInboxTab()).toBe("all");
|
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", () => {
|
it("defaults issue columns to the current inbox layout", () => {
|
||||||
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
|
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 READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
||||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
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 InboxTab = "mine" | "recent" | "unread" | "all";
|
||||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
||||||
|
|
@ -177,6 +178,27 @@ export function resolveIssueWorkspaceName(
|
||||||
return null;
|
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 {
|
export function loadLastInboxTab(): InboxTab {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
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({
|
export function shouldShowInboxSection({
|
||||||
tab,
|
tab,
|
||||||
hasItems,
|
hasItems,
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,7 @@ describe("buildIssueChatMessages", () => {
|
||||||
"system:activity:event-1",
|
"system:activity:event-1",
|
||||||
"user:comment-1",
|
"user:comment-1",
|
||||||
"assistant:comment-2",
|
"assistant:comment-2",
|
||||||
"assistant:live-run:run-live-1",
|
"assistant:run-assistant:run-live-1",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const liveRunMessage = messages.at(-1);
|
const liveRunMessage = messages.at(-1);
|
||||||
|
|
@ -353,7 +353,7 @@ describe("buildIssueChatMessages", () => {
|
||||||
|
|
||||||
expect(messages).toHaveLength(1);
|
expect(messages).toHaveLength(1);
|
||||||
expect(messages[0]).toMatchObject({
|
expect(messages[0]).toMatchObject({
|
||||||
id: "historical-run:run-history-1",
|
id: "run-assistant:run-history-1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
status: { type: "complete", reason: "stop" },
|
status: { type: "complete", reason: "stop" },
|
||||||
metadata: {
|
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", () => {
|
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
|
||||||
const messages = buildIssueChatMessages({
|
const messages = buildIssueChatMessages({
|
||||||
comments: [],
|
comments: [],
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ function createHistoricalTranscriptMessage(args: {
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const message: ThreadAssistantMessage = {
|
const message: ThreadAssistantMessage = {
|
||||||
id: `historical-run:${run.runId}`,
|
id: `run-assistant:${run.runId}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
createdAt: toDate(run.startedAt ?? run.createdAt),
|
createdAt: toDate(run.startedAt ?? run.createdAt),
|
||||||
content,
|
content,
|
||||||
|
|
@ -593,25 +593,20 @@ function normalizeLiveRuns(
|
||||||
function createLiveRunMessage(args: {
|
function createLiveRunMessage(args: {
|
||||||
run: LiveRunForIssue;
|
run: LiveRunForIssue;
|
||||||
transcript: readonly IssueChatTranscriptEntry[];
|
transcript: readonly IssueChatTranscriptEntry[];
|
||||||
hasOutput: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { run, transcript, hasOutput } = args;
|
const { run, transcript } = args;
|
||||||
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
|
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
|
||||||
const waitingText =
|
const waitingText =
|
||||||
run.status === "queued"
|
run.status === "queued"
|
||||||
? "Queued..."
|
? "Queued..."
|
||||||
: hasOutput
|
: parts.length > 0
|
||||||
? ""
|
? ""
|
||||||
: "Working...";
|
: "Working...";
|
||||||
|
|
||||||
const content = parts.length > 0
|
const content = parts;
|
||||||
? parts
|
|
||||||
: waitingText
|
|
||||||
? [{ type: "text", text: waitingText } satisfies TextMessagePart]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const message: ThreadAssistantMessage = {
|
const message: ThreadAssistantMessage = {
|
||||||
id: `live-run:${run.id}`,
|
id: `run-assistant:${run.id}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
createdAt: toDate(run.startedAt ?? run.createdAt),
|
createdAt: toDate(run.startedAt ?? run.createdAt),
|
||||||
content,
|
content,
|
||||||
|
|
@ -684,7 +679,10 @@ export function buildIssueChatMessages(args: {
|
||||||
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
|
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
|
||||||
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
|
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
|
||||||
const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false);
|
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({
|
orderedMessages.push({
|
||||||
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
|
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
|
||||||
order: 2,
|
order: 2,
|
||||||
|
|
@ -697,7 +695,7 @@ export function buildIssueChatMessages(args: {
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (run.status === "succeeded" && !includeSucceededRunsWithoutOutput) continue;
|
if (!includeSucceededRunsWithoutOutput) continue;
|
||||||
orderedMessages.push({
|
orderedMessages.push({
|
||||||
createdAtMs: toTimestamp(runTimestamp(run)),
|
createdAtMs: toTimestamp(runTimestamp(run)),
|
||||||
order: 2,
|
order: 2,
|
||||||
|
|
@ -712,7 +710,6 @@ export function buildIssueChatMessages(args: {
|
||||||
message: createLiveRunMessage({
|
message: createLiveRunMessage({
|
||||||
run,
|
run,
|
||||||
transcript: transcriptsByRunId?.get(run.id) ?? [],
|
transcript: transcriptsByRunId?.get(run.id) ?? [],
|
||||||
hasOutput: hasOutputForRun?.(run.id) ?? false,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
hasBlockingShortcutDialog,
|
hasBlockingShortcutDialog,
|
||||||
isKeyboardShortcutTextInputTarget,
|
isKeyboardShortcutTextInputTarget,
|
||||||
|
resolveIssueDetailGoKeyAction,
|
||||||
resolveInboxQuickArchiveKeyAction,
|
resolveInboxQuickArchiveKeyAction,
|
||||||
} from "./keyboardShortcuts";
|
} from "./keyboardShortcuts";
|
||||||
|
|
||||||
|
|
@ -54,7 +55,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
})).toBe("archive");
|
})).toBe("archive");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disarms on the first non-y keypress", () => {
|
it("ignores non-y keypresses", () => {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
|
|
||||||
expect(resolveInboxQuickArchiveKeyAction({
|
expect(resolveInboxQuickArchiveKeyAction({
|
||||||
|
|
@ -66,7 +67,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
altKey: false,
|
altKey: false,
|
||||||
target: button,
|
target: button,
|
||||||
hasOpenDialog: false,
|
hasOpenDialog: false,
|
||||||
})).toBe("disarm");
|
})).toBe("ignore");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays inert for modifier combos before a real keypress", () => {
|
it("stays inert for modifier combos before a real keypress", () => {
|
||||||
|
|
@ -95,7 +96,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
})).toBe("ignore");
|
})).toBe("ignore");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disarms instead of archiving when typing into an editor", () => {
|
it("ignores input typing instead of archiving", () => {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
|
|
||||||
expect(resolveInboxQuickArchiveKeyAction({
|
expect(resolveInboxQuickArchiveKeyAction({
|
||||||
|
|
@ -107,6 +108,66 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
altKey: false,
|
altKey: false,
|
||||||
target: input,
|
target: input,
|
||||||
hasOpenDialog: false,
|
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");
|
})).toBe("disarm");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
|
||||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||||
|
|
||||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||||
|
export type IssueDetailGoKeyAction = "ignore" | "arm" | "navigate_inbox" | "focus_comment" | "disarm";
|
||||||
|
|
||||||
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
|
@ -46,9 +47,42 @@ export function resolveInboxQuickArchiveKeyAction({
|
||||||
hasOpenDialog: boolean;
|
hasOpenDialog: boolean;
|
||||||
}): InboxQuickArchiveKeyAction {
|
}): InboxQuickArchiveKeyAction {
|
||||||
if (!armed) return "ignore";
|
if (!armed) return "ignore";
|
||||||
if (defaultPrevented) return "disarm";
|
if (defaultPrevented) return "ignore";
|
||||||
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||||
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm";
|
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "ignore";
|
||||||
if (key === "y") return "archive";
|
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";
|
return "disarm";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
ui/src/lib/main-content-focus.test.ts
Normal file
66
ui/src/lib/main-content-focus.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
scheduleMainContentFocus,
|
||||||
|
shouldFocusMainContentAfterNavigation,
|
||||||
|
} from "./main-content-focus";
|
||||||
|
|
||||||
|
describe("main-content-focus", () => {
|
||||||
|
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
|
||||||
|
let originalCancelAnimationFrame: typeof window.cancelAnimationFrame;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
|
||||||
|
window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
|
||||||
|
window.cancelAnimationFrame = ((handle: number) => window.clearTimeout(handle)) as typeof window.cancelAnimationFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the main content when navigation leaves focus outside it", async () => {
|
||||||
|
const sidebarButton = document.createElement("button");
|
||||||
|
const main = document.createElement("main");
|
||||||
|
main.tabIndex = -1;
|
||||||
|
document.body.append(sidebarButton, main);
|
||||||
|
sidebarButton.focus();
|
||||||
|
|
||||||
|
scheduleMainContentFocus(main);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(main);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not steal focus from an active element already inside main content", async () => {
|
||||||
|
const main = document.createElement("main");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
main.tabIndex = -1;
|
||||||
|
main.appendChild(input);
|
||||||
|
document.body.append(main);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
scheduleMainContentFocus(main);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats disconnected elements as needing main-content focus", () => {
|
||||||
|
const main = document.createElement("main");
|
||||||
|
main.tabIndex = -1;
|
||||||
|
document.body.append(main);
|
||||||
|
|
||||||
|
const staleButton = document.createElement("button");
|
||||||
|
staleButton.focus();
|
||||||
|
|
||||||
|
expect(shouldFocusMainContentAfterNavigation(main, staleButton)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
ui/src/lib/main-content-focus.ts
Normal file
21
ui/src/lib/main-content-focus.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export function shouldFocusMainContentAfterNavigation(
|
||||||
|
mainElement: HTMLElement | null,
|
||||||
|
activeElement: Element | null,
|
||||||
|
): boolean {
|
||||||
|
if (!(mainElement instanceof HTMLElement)) return false;
|
||||||
|
if (!(activeElement instanceof HTMLElement)) return true;
|
||||||
|
if (!document.contains(activeElement)) return true;
|
||||||
|
if (activeElement === document.body || activeElement === document.documentElement) return true;
|
||||||
|
return !mainElement.contains(activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleMainContentFocus(mainElement: HTMLElement | null): () => void {
|
||||||
|
if (!(mainElement instanceof HTMLElement)) return () => {};
|
||||||
|
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
if (!shouldFocusMainContentAfterNavigation(mainElement, document.activeElement)) return;
|
||||||
|
mainElement.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
|
applyOptimisticIssueFieldUpdate,
|
||||||
|
applyOptimisticIssueFieldUpdateToCollection,
|
||||||
applyOptimisticIssueCommentUpdate,
|
applyOptimisticIssueCommentUpdate,
|
||||||
createOptimisticIssueComment,
|
createOptimisticIssueComment,
|
||||||
|
flattenIssueCommentPages,
|
||||||
|
getNextIssueCommentPageParam,
|
||||||
isQueuedIssueComment,
|
isQueuedIssueComment,
|
||||||
|
matchesIssueRef,
|
||||||
mergeIssueComments,
|
mergeIssueComments,
|
||||||
upsertIssueComment,
|
upsertIssueComment,
|
||||||
|
upsertIssueCommentInPages,
|
||||||
} from "./optimistic-issue-comments";
|
} from "./optimistic-issue-comments";
|
||||||
|
|
||||||
describe("optimistic issue comments", () => {
|
describe("optimistic issue comments", () => {
|
||||||
|
|
@ -124,6 +131,125 @@ describe("optimistic issue comments", () => {
|
||||||
expect(next[0]?.body).toBe("Updated");
|
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", () => {
|
it("applies optimistic reopen and reassignment updates to the issue cache", () => {
|
||||||
const next = applyOptimisticIssueCommentUpdate(
|
const next = applyOptimisticIssueCommentUpdate(
|
||||||
{
|
{
|
||||||
|
|
@ -177,6 +303,267 @@ describe("optimistic issue comments", () => {
|
||||||
expect(next?.assigneeUserId).toBe("board-2");
|
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", () => {
|
it("treats comments without a run id as queued when they arrive during an active run", () => {
|
||||||
expect(
|
expect(
|
||||||
isQueuedIssueComment({
|
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: {
|
export function createOptimisticIssueComment(params: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
|
|
@ -92,6 +96,20 @@ export function mergeIssueComments(
|
||||||
return sortIssueComments(merged);
|
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(
|
export function upsertIssueComment(
|
||||||
comments: IssueComment[] | undefined,
|
comments: IssueComment[] | undefined,
|
||||||
nextComment: IssueComment,
|
nextComment: IssueComment,
|
||||||
|
|
@ -128,3 +146,106 @@ export function applyOptimisticIssueCommentUpdate(
|
||||||
|
|
||||||
return nextIssue;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
114
ui/src/lib/optimistic-issue-runs.test.ts
Normal file
114
ui/src/lib/optimistic-issue-runs.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { RunForIssue } from "../api/activity";
|
||||||
|
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||||
|
import { removeLiveRunById, upsertInterruptedRun } from "./optimistic-issue-runs";
|
||||||
|
|
||||||
|
function createLiveRun(overrides: Partial<LiveRunForIssue> = {}): LiveRunForIssue {
|
||||||
|
return {
|
||||||
|
id: "run-1",
|
||||||
|
status: "running",
|
||||||
|
invocationSource: "manual",
|
||||||
|
triggerDetail: null,
|
||||||
|
startedAt: "2026-04-08T21:00:00.000Z",
|
||||||
|
finishedAt: null,
|
||||||
|
createdAt: "2026-04-08T21:00:00.000Z",
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentName: "CodexCoder",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
|
||||||
|
return {
|
||||||
|
id: "run-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentName: "CodexCoder",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
invocationSource: "on_demand",
|
||||||
|
triggerDetail: null,
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||||
|
finishedAt: null,
|
||||||
|
error: null,
|
||||||
|
wakeupRequestId: null,
|
||||||
|
exitCode: null,
|
||||||
|
signal: null,
|
||||||
|
usageJson: { inputTokens: 1 },
|
||||||
|
resultJson: { summary: "partial" },
|
||||||
|
sessionIdBefore: null,
|
||||||
|
sessionIdAfter: null,
|
||||||
|
logStore: null,
|
||||||
|
logRef: null,
|
||||||
|
logBytes: null,
|
||||||
|
logSha256: null,
|
||||||
|
logCompressed: false,
|
||||||
|
stdoutExcerpt: null,
|
||||||
|
stderrExcerpt: null,
|
||||||
|
errorCode: null,
|
||||||
|
externalRunId: null,
|
||||||
|
processPid: null,
|
||||||
|
processStartedAt: null,
|
||||||
|
retryOfRunId: null,
|
||||||
|
processLossRetryCount: 0,
|
||||||
|
contextSnapshot: null,
|
||||||
|
createdAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("upsertInterruptedRun", () => {
|
||||||
|
it("adds a synthetic cancelled historical run when the live run has not reached linkedRuns yet", () => {
|
||||||
|
const runs = upsertInterruptedRun(undefined, createLiveRun(), "2026-04-08T21:00:10.000Z");
|
||||||
|
expect(runs).toEqual([{
|
||||||
|
runId: "run-1",
|
||||||
|
status: "cancelled",
|
||||||
|
agentId: "agent-1",
|
||||||
|
startedAt: "2026-04-08T21:00:00.000Z",
|
||||||
|
finishedAt: "2026-04-08T21:00:10.000Z",
|
||||||
|
createdAt: "2026-04-08T21:00:00.000Z",
|
||||||
|
invocationSource: "manual",
|
||||||
|
usageJson: null,
|
||||||
|
resultJson: null,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing linked run in place when the interrupted run is already present", () => {
|
||||||
|
const existing: RunForIssue[] = [{
|
||||||
|
runId: "run-1",
|
||||||
|
status: "running",
|
||||||
|
agentId: "agent-1",
|
||||||
|
startedAt: "2026-04-08T21:00:00.000Z",
|
||||||
|
finishedAt: null,
|
||||||
|
createdAt: "2026-04-08T21:00:00.000Z",
|
||||||
|
invocationSource: "manual",
|
||||||
|
usageJson: { inputTokens: 2 },
|
||||||
|
resultJson: { summary: "partial" },
|
||||||
|
}];
|
||||||
|
|
||||||
|
const runs = upsertInterruptedRun(existing, createActiveRun(), "2026-04-08T21:00:11.000Z");
|
||||||
|
expect(runs).toEqual([{
|
||||||
|
runId: "run-1",
|
||||||
|
status: "cancelled",
|
||||||
|
agentId: "agent-1",
|
||||||
|
startedAt: "2026-04-08T21:00:00.000Z",
|
||||||
|
finishedAt: "2026-04-08T21:00:11.000Z",
|
||||||
|
createdAt: "2026-04-08T21:00:00.000Z",
|
||||||
|
invocationSource: "on_demand",
|
||||||
|
usageJson: { inputTokens: 2 },
|
||||||
|
resultJson: { summary: "partial" },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeLiveRunById", () => {
|
||||||
|
it("removes an interrupted live run from the live list", () => {
|
||||||
|
const runs = removeLiveRunById([
|
||||||
|
createLiveRun(),
|
||||||
|
createLiveRun({ id: "run-2" }),
|
||||||
|
], "run-1");
|
||||||
|
expect(runs?.map((run) => run.id)).toEqual(["run-2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
ui/src/lib/optimistic-issue-runs.ts
Normal file
68
ui/src/lib/optimistic-issue-runs.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { RunForIssue } from "../api/activity";
|
||||||
|
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||||
|
|
||||||
|
export interface InterruptRunSource {
|
||||||
|
id: string;
|
||||||
|
agentId: string;
|
||||||
|
startedAt: Date | string | null;
|
||||||
|
createdAt: Date | string;
|
||||||
|
invocationSource: string;
|
||||||
|
usageJson?: Record<string, unknown> | null;
|
||||||
|
resultJson?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimestamp(value: Date | string | null | undefined) {
|
||||||
|
if (!value) return 0;
|
||||||
|
return new Date(value).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoString(value: Date | string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertInterruptedRun(
|
||||||
|
runs: RunForIssue[] | undefined,
|
||||||
|
run: InterruptRunSource,
|
||||||
|
finishedAt: string,
|
||||||
|
): RunForIssue[] {
|
||||||
|
const nextRun: RunForIssue = {
|
||||||
|
runId: run.id,
|
||||||
|
status: "cancelled",
|
||||||
|
agentId: run.agentId,
|
||||||
|
startedAt: toIsoString(run.startedAt),
|
||||||
|
finishedAt,
|
||||||
|
createdAt: toIsoString(run.createdAt) ?? finishedAt,
|
||||||
|
invocationSource: run.invocationSource,
|
||||||
|
usageJson: run.usageJson ?? null,
|
||||||
|
resultJson: run.resultJson ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const current = runs ?? [];
|
||||||
|
const existingIndex = current.findIndex((entry) => entry.runId === run.id);
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return [...current, nextRun].sort((a, b) => {
|
||||||
|
const diff = toTimestamp(a.startedAt ?? a.createdAt) - toTimestamp(b.startedAt ?? b.createdAt);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return a.runId.localeCompare(b.runId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = [...current];
|
||||||
|
updated[existingIndex] = {
|
||||||
|
...updated[existingIndex],
|
||||||
|
...nextRun,
|
||||||
|
usageJson: updated[existingIndex]?.usageJson ?? nextRun.usageJson,
|
||||||
|
resultJson: updated[existingIndex]?.resultJson ?? nextRun.resultJson,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLiveRunById(
|
||||||
|
runs: LiveRunForIssue[] | undefined,
|
||||||
|
runId: string,
|
||||||
|
) {
|
||||||
|
if (!runs) return runs;
|
||||||
|
const nextRuns = runs.filter((run) => run.id !== runId);
|
||||||
|
return nextRuns.length === runs.length ? runs : nextRuns;
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,8 @@ export const queryKeys = {
|
||||||
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||||
listByProject: (companyId: string, projectId: string) =>
|
listByProject: (companyId: string, projectId: string) =>
|
||||||
["issues", companyId, "project", projectId] as const,
|
["issues", companyId, "project", projectId] as const,
|
||||||
|
listByParent: (companyId: string, parentId: string) =>
|
||||||
|
["issues", companyId, "parent", parentId] as const,
|
||||||
listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) =>
|
listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) =>
|
||||||
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
|
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
|
||||||
detail: (id: string) => ["issues", "detail", id] as const,
|
detail: (id: string) => ["issues", "detail", id] as const,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,7 @@ import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
||||||
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
armIssueDetailInboxQuickArchive,
|
armIssueDetailInboxQuickArchive,
|
||||||
|
|
@ -26,17 +27,21 @@ import {
|
||||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import {
|
||||||
|
InboxIssueMetaLeading,
|
||||||
|
InboxIssueTrailingColumns,
|
||||||
|
IssueColumnPicker,
|
||||||
|
issueActivityText,
|
||||||
|
issueTrailingColumns,
|
||||||
|
} from "../components/IssueColumns";
|
||||||
import { IssueRow } from "../components/IssueRow";
|
import { IssueRow } from "../components/IssueRow";
|
||||||
import { SwipeToArchive } from "../components/SwipeToArchive";
|
import { SwipeToArchive } from "../components/SwipeToArchive";
|
||||||
|
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { Identity } from "../components/Identity";
|
|
||||||
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -48,15 +53,6 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -67,12 +63,13 @@ import {
|
||||||
import {
|
import {
|
||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
XCircle,
|
XCircle,
|
||||||
X,
|
X,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Columns3,
|
|
||||||
Search,
|
Search,
|
||||||
|
ListTree,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
|
|
@ -80,6 +77,7 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh
|
||||||
import {
|
import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||||
|
buildInboxNesting,
|
||||||
getAvailableInboxIssueColumns,
|
getAvailableInboxIssueColumns,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
getInboxWorkItems,
|
getInboxWorkItems,
|
||||||
|
|
@ -89,10 +87,13 @@ import {
|
||||||
isInboxEntityDismissed,
|
isInboxEntityDismissed,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
loadInboxIssueColumns,
|
loadInboxIssueColumns,
|
||||||
|
loadInboxNesting,
|
||||||
normalizeInboxIssueColumns,
|
normalizeInboxIssueColumns,
|
||||||
|
resolveInboxNestingEnabled,
|
||||||
resolveIssueWorkspaceName,
|
resolveIssueWorkspaceName,
|
||||||
resolveInboxSelectionIndex,
|
resolveInboxSelectionIndex,
|
||||||
saveInboxIssueColumns,
|
saveInboxIssueColumns,
|
||||||
|
saveInboxNesting,
|
||||||
InboxApprovalFilter,
|
InboxApprovalFilter,
|
||||||
type InboxIssueColumn,
|
type InboxIssueColumn,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
|
|
@ -102,6 +103,8 @@ import {
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
|
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
|
||||||
|
|
||||||
type InboxCategoryFilter =
|
type InboxCategoryFilter =
|
||||||
| "everything"
|
| "everything"
|
||||||
| "issues_i_touched"
|
| "issues_i_touched"
|
||||||
|
|
@ -113,6 +116,11 @@ type SectionKey =
|
||||||
| "work_items"
|
| "work_items"
|
||||||
| "alerts";
|
| "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 {
|
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
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;
|
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({
|
export function FailedRunInboxRow({
|
||||||
run,
|
run,
|
||||||
|
|
@ -813,6 +582,7 @@ function JoinRequestInboxRow({
|
||||||
export function Inbox() {
|
export function Inbox() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -1029,7 +799,7 @@ export function Inbox() {
|
||||||
);
|
);
|
||||||
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
|
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
|
||||||
const visibleTrailingIssueColumns = useMemo(
|
const visibleTrailingIssueColumns = useMemo(
|
||||||
() => trailingIssueColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
||||||
[availableIssueColumnSet, visibleIssueColumnSet],
|
[availableIssueColumnSet, visibleIssueColumnSet],
|
||||||
);
|
);
|
||||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||||
|
|
@ -1154,6 +924,51 @@ export function Inbox() {
|
||||||
projectWorkspaceById,
|
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) => {
|
const agentName = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return agentById.get(id) ?? 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.
|
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, filteredWorkItems.length));
|
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, flatNavItems.length));
|
||||||
}, [filteredWorkItems.length]);
|
}, [flatNavItems.length]);
|
||||||
|
|
||||||
// Use refs for keyboard handler to avoid stale closures
|
// Use refs for keyboard handler to avoid stale closures
|
||||||
const kbStateRef = useRef({
|
const kbStateRef = useRef({
|
||||||
workItems: filteredWorkItems,
|
workItems: nestedWorkItems,
|
||||||
|
flatNavItems,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
canArchive: canArchiveFromTab,
|
canArchive: canArchiveFromTab,
|
||||||
archivingIssueIds,
|
archivingIssueIds,
|
||||||
|
|
@ -1441,7 +1257,8 @@ export function Inbox() {
|
||||||
readItems,
|
readItems,
|
||||||
});
|
});
|
||||||
kbStateRef.current = {
|
kbStateRef.current = {
|
||||||
workItems: filteredWorkItems,
|
workItems: nestedWorkItems,
|
||||||
|
flatNavItems,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
canArchive: canArchiveFromTab,
|
canArchive: canArchiveFromTab,
|
||||||
archivingIssueIds,
|
archivingIssueIds,
|
||||||
|
|
@ -1495,77 +1312,94 @@ export function Inbox() {
|
||||||
// Keyboard shortcuts are only active on the "mine" tab
|
// Keyboard shortcuts are only active on the "mine" tab
|
||||||
if (!st.canArchive) return;
|
if (!st.canArchive) return;
|
||||||
|
|
||||||
const itemCount = st.workItems.length;
|
const navItems = st.flatNavItems;
|
||||||
if (itemCount === 0) return;
|
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) {
|
switch (e.key) {
|
||||||
case "j": {
|
case "j": {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
|
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "next"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "k": {
|
case "k": {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
|
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "previous"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
case "y": {
|
case "y": {
|
||||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = st.workItems[st.selectedIndex];
|
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||||
if (item.kind === "issue") {
|
if (issue) {
|
||||||
if (!st.archivingIssueIds.has(item.issue.id)) {
|
if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
|
||||||
act.archiveIssue(item.issue.id);
|
} else if (item) {
|
||||||
}
|
if (item.kind === "issue") {
|
||||||
} else {
|
if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id);
|
||||||
const key = getWorkItemKey(item);
|
} else {
|
||||||
if (!st.archivingNonIssueIds.has(key)) {
|
const key = getWorkItemKey(item);
|
||||||
act.archiveNonIssue(key);
|
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "U": {
|
case "U": {
|
||||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = st.workItems[st.selectedIndex];
|
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||||
if (item.kind === "issue") {
|
if (issue) {
|
||||||
act.markUnreadIssue(item.issue.id);
|
act.markUnreadIssue(issue.id);
|
||||||
} else {
|
} else if (item) {
|
||||||
act.markNonIssueUnread(getWorkItemKey(item));
|
if (item.kind === "issue") act.markUnreadIssue(item.issue.id);
|
||||||
|
else act.markNonIssueUnread(getWorkItemKey(item));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "r": {
|
case "r": {
|
||||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = st.workItems[st.selectedIndex];
|
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||||
if (item.kind === "issue") {
|
if (issue) {
|
||||||
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
|
if (issue.isUnreadForMe && !st.fadingOutIssues.has(issue.id)) act.markRead(issue.id);
|
||||||
act.markRead(item.issue.id);
|
} else if (item) {
|
||||||
}
|
if (item.kind === "issue") {
|
||||||
} else {
|
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) act.markRead(item.issue.id);
|
||||||
const key = getWorkItemKey(item);
|
} else {
|
||||||
if (!st.readItems.has(key)) {
|
const key = getWorkItemKey(item);
|
||||||
act.markNonIssueRead(key);
|
if (!st.readItems.has(key)) act.markNonIssueRead(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "Enter": {
|
case "Enter": {
|
||||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = st.workItems[st.selectedIndex];
|
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||||
if (item.kind === "issue") {
|
if (issue) {
|
||||||
const pathId = item.issue.identifier ?? item.issue.id;
|
const pathId = issue.identifier ?? issue.id;
|
||||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||||
rememberIssueDetailLocationState(pathId, detailState);
|
rememberIssueDetailLocationState(pathId, detailState);
|
||||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||||
} else if (item.kind === "approval") {
|
} else if (item) {
|
||||||
act.navigate(`/approvals/${item.approval.id}`);
|
if (item.kind === "issue") {
|
||||||
} else if (item.kind === "failed_run") {
|
const pathId = item.issue.identifier ?? item.issue.id;
|
||||||
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1601,7 +1435,7 @@ export function Inbox() {
|
||||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||||
!dismissedAlerts.has("alert:budget");
|
!dismissedAlerts.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const showWorkItemsSection = filteredWorkItems.length > 0;
|
const showWorkItemsSection = nestedWorkItems.length > 0;
|
||||||
const showAlertsSection = shouldShowInboxSection({
|
const showAlertsSection = shouldShowInboxSection({
|
||||||
tab,
|
tab,
|
||||||
hasItems: hasAlerts,
|
hasItems: hasAlerts,
|
||||||
|
|
@ -1674,58 +1508,23 @@ export function Inbox() {
|
||||||
className="h-8 w-[220px] pl-8 text-xs"
|
className="h-8 w-[220px] pl-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<Button
|
||||||
<DropdownMenuTrigger asChild>
|
type="button"
|
||||||
<Button
|
variant="outline"
|
||||||
type="button"
|
size="icon"
|
||||||
variant="ghost"
|
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
|
||||||
size="sm"
|
onClick={toggleNesting}
|
||||||
className="hidden h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground sm:inline-flex"
|
title={nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
|
||||||
>
|
>
|
||||||
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
<ListTree className="h-3.5 w-3.5" />
|
||||||
Show / hide columns
|
</Button>
|
||||||
</Button>
|
<IssueColumnPicker
|
||||||
</DropdownMenuTrigger>
|
availableColumns={availableIssueColumns}
|
||||||
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
visibleColumnSet={visibleIssueColumnSet}
|
||||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
|
onToggleColumn={toggleIssueColumn}
|
||||||
<div className="space-y-1">
|
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
title="Choose which inbox columns stay visible"
|
||||||
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>
|
|
||||||
{canMarkAllRead && (
|
{canMarkAllRead && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1833,13 +1632,34 @@ export function Inbox() {
|
||||||
{showSeparatorBefore("work_items") && <Separator />}
|
{showSeparatorBefore("work_items") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
<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) => (
|
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||||
<div
|
<div
|
||||||
key={`sel-${key}`}
|
key={`sel-${key}`}
|
||||||
data-inbox-item
|
data-inbox-item
|
||||||
className="relative"
|
className="relative"
|
||||||
onClick={() => setSelectedIndex(index)}
|
onClick={() => setSelectedIndex(navIdx)}
|
||||||
>
|
>
|
||||||
{child}
|
{child}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1849,7 +1669,7 @@ export function Inbox() {
|
||||||
index > 0 &&
|
index > 0 &&
|
||||||
item.timestamp > 0 &&
|
item.timestamp > 0 &&
|
||||||
item.timestamp < todayCutoff &&
|
item.timestamp < todayCutoff &&
|
||||||
filteredWorkItems[index - 1].timestamp >= todayCutoff;
|
nestedWorkItems[index - 1].timestamp >= todayCutoff;
|
||||||
const elements: ReactNode[] = [];
|
const elements: ReactNode[] = [];
|
||||||
if (showTodayDivider) {
|
if (showTodayDivider) {
|
||||||
elements.push(
|
elements.push(
|
||||||
|
|
@ -1861,7 +1681,7 @@ export function Inbox() {
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const isSelected = selectedIndex === index;
|
const isSelected = selectedIndex === navIdx;
|
||||||
|
|
||||||
if (item.kind === "approval") {
|
if (item.kind === "approval") {
|
||||||
const approvalKey = `approval:${item.approval.id}`;
|
const approvalKey = `approval:${item.approval.id}`;
|
||||||
|
|
@ -1973,74 +1793,150 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const issue = item.issue;
|
const issue = item.issue;
|
||||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
const childIssues = childrenByIssueId.get(issue.id) ?? [];
|
||||||
const isFading = fadingOutIssues.has(issue.id);
|
const hasChildren = childIssues.length > 0;
|
||||||
const isArchiving = archivingIssueIds.has(issue.id);
|
const isExpanded = hasChildren && !collapsedInboxParents.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 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 ? (
|
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={`issue:${issue.id}`}
|
key={`issue:${issue.id}`}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
disabled={isArchiving || archiveIssueMutation.isPending}
|
disabled={archivingIssueIds.has(issue.id) || archiveIssueMutation.isPending}
|
||||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||||
>
|
>
|
||||||
{row}
|
{parentRow}
|
||||||
</SwipeToArchive>
|
</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;
|
return elements;
|
||||||
})}
|
});
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
issueChatUxTranscriptsByRunId,
|
issueChatUxTranscriptsByRunId,
|
||||||
} from "../fixtures/issueChatUxFixtures";
|
} from "../fixtures/issueChatUxFixtures";
|
||||||
import { cn } from "../lib/utils";
|
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 () => {};
|
const noop = async () => {};
|
||||||
|
|
||||||
|
|
@ -216,6 +216,43 @@ export function IssueChatUxLab() {
|
||||||
</div>
|
</div>
|
||||||
</LabSection>
|
</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
|
<LabSection
|
||||||
id="live-execution"
|
id="live-execution"
|
||||||
eyebrow="Primary preview"
|
eyebrow="Primary preview"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -173,6 +173,11 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
const { data: projects } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(companyId),
|
||||||
|
queryFn: () => projectsApi.list(companyId),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
const liveIssueIds = useMemo(() => {
|
const liveIssueIds = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
@ -203,6 +208,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error as Error | null}
|
error={error as Error | null}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
|
projects={projects}
|
||||||
liveIssueIds={liveIssueIds}
|
liveIssueIds={liveIssueIds}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
viewStateKey={`paperclip:project-view:${projectId}`}
|
viewStateKey={`paperclip:project-view:${projectId}`}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue