Merge pull request #3222 from paperclipai/pap-1266-issue-workflow

feat(issue-ui): refine issue workflow surfaces and live updates
This commit is contained in:
Dotta 2026-04-09 14:52:16 -05:00 committed by GitHub
commit 0e87fdbe35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2860 additions and 1206 deletions

View file

@ -30,8 +30,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
const runs = liveRuns ?? [];
const { data: issues } = useQuery({
queryKey: queryKeys.issues.list(companyId),
queryFn: () => issuesApi.list(companyId),
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
enabled: runs.length > 0,
});

View file

@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { Link, useLocation } from "react-router-dom";
import type {
Agent,
@ -631,7 +631,7 @@ const TimelineList = memo(function TimelineList({
);
});
export const CommentThread = memo(function CommentThread({
export function CommentThread({
comments,
queuedComments = [],
linkedApprovals = [],
@ -662,9 +662,17 @@ export const CommentThread = memo(function CommentThread({
interruptingQueuedRunId = null,
composerDisabledReason = null,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const location = useLocation();
const hasScrolledRef = useRef(false);
@ -730,6 +738,29 @@ export const CommentThread = memo(function CommentThread({
}));
}, [agentMap, providedMentions]);
useEffect(() => {
if (!draftKey) return;
setBody(loadDraft(draftKey));
}, [draftKey]);
useEffect(() => {
if (!draftKey) return;
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => {
saveDraft(draftKey, body);
}, DRAFT_DEBOUNCE_MS);
}, [body, draftKey]);
useEffect(() => {
return () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
};
}, []);
useEffect(() => {
setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]);
// Scroll to comment when URL hash matches #comment-{id}
useEffect(() => {
const hash = location.hash;
@ -748,25 +779,72 @@ export const CommentThread = memo(function CommentThread({
}
}, [location.hash, comments, queuedComments]);
const handleFeedbackVote = useCallback(
async (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => {
if (!onVote) return;
setVotingTargetId(commentId);
try {
await onVote(commentId, vote, options);
} finally {
setVotingTargetId(null);
}
},
[onVote],
);
async function handleSubmit() {
const trimmed = body.trim();
if (!trimmed) return;
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
const submittedBody = trimmed;
setSubmitting(true);
setBody("");
try {
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReopen(true);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
setBody((current) =>
restoreSubmittedCommentDraft({
currentBody: current,
submittedBody,
}),
);
// Parent mutation handlers surface the failure and the draft is restored for retry.
} finally {
setSubmitting(false);
}
}
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file) return;
setAttaching(true);
try {
if (imageUploadHandler) {
const url = await imageUploadHandler(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
} else if (onAttachImage) {
await onAttachImage(file);
}
} finally {
setAttaching(false);
if (attachInputRef.current) attachInputRef.current.value = "";
}
}
async function handleFeedbackVote(
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) {
if (!onVote) return;
setVotingTargetId(commentId);
try {
await onVote(commentId, vote, options);
} finally {
setVotingTargetId(null);
}
}
const canSubmit = !submitting && !!body.trim();
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
const timelineSection = useMemo(
() => (
<TimelineList
timeline={timeline}
agentMap={agentMap}
@ -783,21 +861,6 @@ export const CommentThread = memo(function CommentThread({
highlightCommentId={highlightCommentId}
feedbackTermsUrl={feedbackTermsUrl}
/>
),
[
timeline, agentMap, currentUserId, companyId, projectId,
onApproveApproval, onRejectApproval, pendingApprovalAction,
feedbackVoteByTargetId, feedbackDataSharingPreference,
onVote, handleFeedbackVote, votingTargetId, highlightCommentId,
feedbackTermsUrl,
],
);
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
{timelineSection}
{liveRunSlot}
@ -840,216 +903,92 @@ export const CommentThread = memo(function CommentThread({
{composerDisabledReason}
</div>
) : (
<CommentComposer
onAdd={onAdd}
mentions={mentions}
imageUploadHandler={imageUploadHandler}
onAttachImage={onAttachImage}
draftKey={draftKey}
enableReassign={enableReassign}
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
suggestedAssigneeValue={effectiveSuggestedAssigneeValue}
agentMap={agentMap}
/>
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
options={reassignOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={setReassignTarget}
className="text-xs h-8"
renderTriggerValue={(option) => {
if (!option) return <span className="text-muted-foreground">Assignee</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
)}
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
{submitting ? "Posting..." : "Comment"}
</Button>
</div>
</div>
)}
</div>
);
});
CommentThread.displayName = "CommentThread";
/* ---- Isolated Composer (body state lives here, not in CommentThread) ---- */
interface CommentComposerProps {
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
mentions: MentionOption[];
imageUploadHandler?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
enableReassign: boolean;
reassignOptions: InlineEntityOption[];
currentAssigneeValue: string;
suggestedAssigneeValue: string;
agentMap?: Map<string, Agent>;
}
const CommentComposer = memo(function CommentComposer({
onAdd,
mentions,
imageUploadHandler,
onAttachImage,
draftKey,
enableReassign,
reassignOptions,
currentAssigneeValue,
suggestedAssigneeValue,
agentMap,
}: CommentComposerProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const [reassignTarget, setReassignTarget] = useState(suggestedAssigneeValue);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!draftKey) return;
setBody(loadDraft(draftKey));
}, [draftKey]);
useEffect(() => {
if (!draftKey) return;
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => {
saveDraft(draftKey, body);
}, DRAFT_DEBOUNCE_MS);
}, [body, draftKey]);
useEffect(() => {
return () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
};
}, []);
useEffect(() => {
setReassignTarget(suggestedAssigneeValue);
}, [suggestedAssigneeValue]);
async function handleSubmit() {
const trimmed = body.trim();
if (!trimmed) return;
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
const submittedBody = trimmed;
setSubmitting(true);
setBody("");
try {
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReopen(true);
setReassignTarget(suggestedAssigneeValue);
} catch {
setBody((current) =>
restoreSubmittedCommentDraft({
currentBody: current,
submittedBody,
}),
);
} finally {
setSubmitting(false);
}
}
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file) return;
setAttaching(true);
try {
if (imageUploadHandler) {
const url = await imageUploadHandler(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
} else if (onAttachImage) {
await onAttachImage(file);
}
} finally {
setAttaching(false);
if (attachInputRef.current) attachInputRef.current.value = "";
}
}
const canSubmit = !submitting && !!body.trim();
return (
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
options={reassignOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={setReassignTarget}
className="text-xs h-8"
renderTriggerValue={(option) => {
if (!option) return <span className="text-muted-foreground">Assignee</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
)}
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
{submitting ? "Posting..." : "Comment"}
</Button>
</div>
</div>
);
});

View file

@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() {
@ -33,6 +34,7 @@ export function InstanceSidebar() {
<NavLink
key={plugin.id}
to={`/instance/settings/plugins/${plugin.id}`}
state={SIDEBAR_SCROLL_RESET_STATE}
className={({ isActive }) =>
[
"rounded-md px-2 py-1.5 text-xs transition-colors",

View file

@ -41,6 +41,7 @@ import {
type IssueChatTranscriptEntry,
type SegmentTiming,
} from "../lib/issue-chat-messages";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@ -907,8 +908,6 @@ function IssueChatUserMessage() {
) : null}
</div>
) : null}
{pending ? <div className="mb-1 text-xs text-muted-foreground">Sending...</div> : null}
<div className="space-y-3">
<MessagePrimitive.Parts
components={{
@ -918,39 +917,43 @@ function IssueChatUserMessage() {
</div>
</div>
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
const text = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
{pending ? (
<div className="mt-1 flex justify-end px-1 text-[11px] text-muted-foreground">Sending...</div>
) : (
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
const text = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)}
</div>
<Avatar size="sm" className="mt-1 shrink-0">
@ -1820,26 +1823,12 @@ export function IssueChatThread({
return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}, [activeRun, liveRuns]);
const transcriptRuns = useMemo(() => {
const combined = new Map<string, { id: string; status: string; adapterType: string }>();
for (const run of displayLiveRuns) {
combined.set(run.id, {
id: run.id,
status: run.status,
adapterType: run.adapterType,
});
}
for (const run of linkedRuns) {
if (combined.has(run.runId)) continue;
const adapterType = agentMap?.get(run.agentId)?.adapterType;
if (!adapterType) continue;
combined.set(run.runId, {
id: run.runId,
status: run.status,
adapterType,
});
}
return [...combined.values()];
}, [agentMap, displayLiveRuns, linkedRuns]);
return resolveIssueChatTranscriptRuns({
linkedRuns,
liveRuns: displayLiveRuns,
activeRun,
});
}, [activeRun, displayLiveRuns, linkedRuns]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
companyId,

View file

@ -351,4 +351,51 @@ describe("IssueDocumentsSection", () => {
});
queryClient.clear();
});
it("wraps the documents header actions so mobile layouts do not overflow", async () => {
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument()]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={false}
extraActions={(
<>
<button type="button">Upload</button>
<button type="button">Sub-issue</button>
</>
)}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const heading = container.querySelector("h3");
expect(heading).toBeTruthy();
expect(heading?.parentElement?.className).toContain("flex-wrap");
expect(heading?.nextElementSibling?.className).toContain("flex-wrap");
await act(async () => {
root.unmount();
});
queryClient.clear();
});
});

View file

@ -683,7 +683,7 @@ export function IssueDocumentsSection({
return (
<div className="space-y-3">
{isEmpty && !draft?.isNew ? (
<div className="flex items-center justify-end gap-2 min-w-0">
<div className="flex flex-wrap items-center justify-end gap-2 min-w-0">
{extraActions}
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
<Plus className="mr-1.5 h-3.5 w-3.5" />
@ -692,9 +692,9 @@ export function IssueDocumentsSection({
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2 min-w-0">
<h3 className="text-sm font-medium text-muted-foreground shrink-0">Documents</h3>
<div className="flex items-center gap-2 min-w-0">
<div className="flex flex-wrap items-center gap-2 min-w-0">
<h3 className="w-full text-sm font-medium text-muted-foreground shrink-0 sm:w-auto">Documents</h3>
<div className="flex flex-wrap items-center gap-2 min-w-0 sm:ml-auto">
{extraActions}
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
<Plus className="mr-1.5 h-3.5 w-3.5" />

View file

@ -0,0 +1,232 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Filter, X, User } from "lucide-react";
import { PriorityIcon } from "./PriorityIcon";
import { StatusIcon } from "./StatusIcon";
import {
defaultIssueFilterState,
issueFilterArraysEqual,
issueFilterLabel,
issuePriorityOrder,
issueQuickFilterPresets,
issueStatusOrder,
toggleIssueFilterValue,
type IssueFilterState,
} from "../lib/issue-filters";
type AgentOption = {
id: string;
name: string;
};
type ProjectOption = {
id: string;
name: string;
};
type LabelOption = {
id: string;
name: string;
color: string;
};
export function IssueFiltersPopover({
state,
onChange,
activeFilterCount,
agents,
projects,
labels,
currentUserId,
enableRoutineVisibilityFilter = false,
buttonVariant = "ghost",
}: {
state: IssueFilterState;
onChange: (patch: Partial<IssueFilterState>) => void;
activeFilterCount: number;
agents?: AgentOption[];
projects?: ProjectOption[];
labels?: LabelOption[];
currentUserId?: string | null;
enableRoutineVisibilityFilter?: boolean;
buttonVariant?: "ghost" | "outline";
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant={buttonVariant} size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}>
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>
{activeFilterCount > 0 ? <span className="ml-0.5 text-[10px] font-medium sm:hidden">{activeFilterCount}</span> : null}
{activeFilterCount > 0 ? (
<X
className="ml-1 hidden h-3 w-3 sm:block"
onClick={(event) => {
event.stopPropagation();
onChange(defaultIssueFilterState);
}}
/>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(480px,calc(100vw-2rem))] p-0">
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filters</span>
{activeFilterCount > 0 ? (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => onChange(defaultIssueFilterState)}
>
Clear
</button>
) : null}
</div>
<div className="space-y-1.5">
<span className="text-xs text-muted-foreground">Quick filters</span>
<div className="flex flex-wrap gap-1.5">
{issueQuickFilterPresets.map((preset) => {
const isActive = issueFilterArraysEqual(state.statuses, preset.statuses);
return (
<button
key={preset.label}
type="button"
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
}`}
onClick={() => onChange({ statuses: isActive ? [] : [...preset.statuses] })}
>
{preset.label}
</button>
);
})}
</div>
</div>
<div className="border-t border-border" />
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Status</span>
<div className="space-y-0.5">
{issueStatusOrder.map((status) => (
<label key={status} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.statuses.includes(status)}
onCheckedChange={() => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })}
/>
<StatusIcon status={status} />
<span className="text-sm">{issueFilterLabel(status)}</span>
</label>
))}
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Priority</span>
<div className="space-y-0.5">
{issuePriorityOrder.map((priority) => (
<label key={priority} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.priorities.includes(priority)}
onCheckedChange={() => onChange({ priorities: toggleIssueFilterValue(state.priorities, priority) })}
/>
<PriorityIcon priority={priority} />
<span className="text-sm">{issueFilterLabel(priority)}</span>
</label>
))}
</div>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.assignees.includes("__unassigned")}
onCheckedChange={() => onChange({ assignees: toggleIssueFilterValue(state.assignees, "__unassigned") })}
/>
<span className="text-sm">No assignee</span>
</label>
{currentUserId ? (
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.assignees.includes("__me")}
onCheckedChange={() => onChange({ assignees: toggleIssueFilterValue(state.assignees, "__me") })}
/>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">Me</span>
</label>
) : null}
{(agents ?? []).map((agent) => (
<label key={agent.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.assignees.includes(agent.id)}
onCheckedChange={() => onChange({ assignees: toggleIssueFilterValue(state.assignees, agent.id) })}
/>
<span className="text-sm">{agent.name}</span>
</label>
))}
</div>
</div>
{labels && labels.length > 0 ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Labels</span>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
{labels.map((label) => (
<label key={label.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.labels.includes(label.id)}
onCheckedChange={() => onChange({ labels: toggleIssueFilterValue(state.labels, label.id) })}
/>
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: label.color }} />
<span className="text-sm">{label.name}</span>
</label>
))}
</div>
</div>
) : null}
{projects && projects.length > 0 ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Project</span>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
{projects.map((project) => (
<label key={project.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.projects.includes(project.id)}
onCheckedChange={() => onChange({ projects: toggleIssueFilterValue(state.projects, project.id) })}
/>
<span className="text-sm">{project.name}</span>
</label>
))}
</div>
</div>
) : null}
{enableRoutineVisibilityFilter ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Visibility</span>
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.showRoutineExecutions}
onCheckedChange={(checked) => onChange({ showRoutineExecutions: checked === true })}
/>
<span className="text-sm">Show routine runs</span>
</label>
</div>
) : null}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -2,7 +2,11 @@ import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
@ -48,13 +52,14 @@ export function IssueRow({
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
return (
<Link
to={createIssueDetailPath(issuePathId)}
state={issueLinkState}
state={detailState}
data-inbox-issue-link
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, issueLinkState)}
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
className={cn(
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",

View file

@ -307,4 +307,67 @@ describe("IssuesList", () => {
root.unmount();
});
});
it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => {
const manualIssue = createIssue({
id: "issue-manual",
identifier: "PAP-10",
title: "Manual issue",
originKind: "manual",
});
const routineIssue = createIssue({
id: "issue-routine",
identifier: "PAP-11",
title: "Routine issue",
originKind: "routine_execution",
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[manualIssue, routineIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
enableRoutineVisibilityFilter
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.textContent).toContain("Manual issue");
expect(container.textContent).not.toContain("Routine issue");
});
await act(async () => {
const filterButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Filter"),
);
filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
await waitForAssertion(() => {
const toggle = Array.from(document.body.querySelectorAll("label")).find(
(label) => label.textContent?.includes("Show routine runs"),
);
expect(toggle).not.toBeUndefined();
});
await act(async () => {
const toggle = Array.from(document.body.querySelectorAll("label")).find(
(label) => label.textContent?.includes("Show routine runs"),
);
toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
await waitForAssertion(() => {
expect(container.textContent).toContain("Routine issue");
});
act(() => {
root.unmount();
});
});
});

View file

@ -9,6 +9,15 @@ import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { groupBy } from "../lib/groupBy";
import {
applyIssueFilters,
countActiveIssueFilters,
defaultIssueFilterState,
issueFilterLabel,
issuePriorityOrder,
issueStatusOrder,
type IssueFilterState,
} from "../lib/issue-filters";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
getAvailableInboxIssueColumns,
@ -27,39 +36,24 @@ import {
issueTrailingColumns,
} from "./IssueColumns";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { EmptyState } from "./EmptyState";
import { Identity } from "./Identity";
import { IssueFiltersPopover } from "./IssueFiltersPopover";
import { IssueRow } from "./IssueRow";
import { PageSkeleton } from "./PageSkeleton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react";
import { KanbanBoard } from "./KanbanBoard";
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import type { Issue, Project } from "@paperclipai/shared";
/* ── Helpers ── */
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
const priorityOrder = ["critical", "high", "medium", "low"];
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/* ── View state ── */
export type IssueViewState = {
statuses: string[];
priorities: string[];
assignees: string[];
labels: string[];
projects: string[];
export type IssueViewState = IssueFilterState & {
sortField: "status" | "priority" | "title" | "created" | "updated";
sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
@ -69,11 +63,7 @@ export type IssueViewState = {
};
const defaultViewState: IssueViewState = {
statuses: [],
priorities: [],
assignees: [],
labels: [],
projects: [],
...defaultIssueFilterState,
sortField: "updated",
sortDir: "desc",
groupBy: "none",
@ -81,13 +71,6 @@ const defaultViewState: IssueViewState = {
collapsedGroups: [],
collapsedParents: [],
};
const quickFilterPresets = [
{ label: "All", statuses: [] as string[] },
{ label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] },
{ label: "Backlog", statuses: ["backlog"] },
{ label: "Done", statuses: ["done", "cancelled"] },
];
function getViewState(key: string): IssueViewState {
try {
const raw = localStorage.getItem(key);
@ -100,45 +83,15 @@ function saveViewState(key: string, state: IssueViewState) {
localStorage.setItem(key, JSON.stringify(state));
}
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sa = [...a].sort();
const sb = [...b].sort();
return sa.every((v, i) => v === sb[i]);
}
function toggleInArray(arr: string[], value: string): string[] {
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
}
function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] {
let result = issues;
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status));
if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority));
if (state.assignees.length > 0) {
result = result.filter((issue) => {
for (const assignee of state.assignees) {
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
if (issue.assigneeAgentId === assignee) return true;
}
return false;
});
}
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId));
return result;
}
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
const sorted = [...issues];
const dir = state.sortDir === "asc" ? 1 : -1;
sorted.sort((a, b) => {
switch (state.sortField) {
case "status":
return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status));
return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status));
case "priority":
return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority));
return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority));
case "title":
return dir * a.title.localeCompare(b.title);
case "created":
@ -152,16 +105,6 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
return sorted;
}
function countActiveFilters(state: IssueViewState): number {
let count = 0;
if (state.statuses.length > 0) count++;
if (state.priorities.length > 0) count++;
if (state.assignees.length > 0) count++;
if (state.labels.length > 0) count++;
if (state.projects.length > 0) count++;
return count;
}
/* ── Component ── */
interface Agent {
@ -186,6 +129,7 @@ interface IssuesListProps {
searchFilters?: {
participantAgentId?: string;
};
enableRoutineVisibilityFilter?: boolean;
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
@ -247,6 +191,7 @@ export function IssuesList({
initialAssignees,
initialSearch,
searchFilters,
enableRoutineVisibilityFilter = false,
onSearchChange,
onUpdateIssue,
}: IssuesListProps) {
@ -319,8 +264,15 @@ export function IssuesList({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
],
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
q: normalizedIssueSearch,
projectId,
...searchFilters,
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
}),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
placeholderData: (previousData) => previousData,
});
@ -423,9 +375,9 @@ export function IssuesList({
const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter);
return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
@ -433,7 +385,7 @@ export function IssuesList({
enabled: !!selectedCompanyId,
});
const activeFilterCount = countActiveFilters(viewState);
const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter);
const groupedContent = useMemo(() => {
if (viewState.groupBy === "none") {
@ -441,15 +393,15 @@ export function IssuesList({
}
if (viewState.groupBy === "status") {
const groups = groupBy(filtered, (i) => i.status);
return statusOrder
return issueStatusOrder
.filter((s) => groups[s]?.length)
.map((s) => ({ key: s, label: statusLabel(s), items: groups[s]! }));
.map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! }));
}
if (viewState.groupBy === "priority") {
const groups = groupBy(filtered, (i) => i.priority);
return priorityOrder
return issuePriorityOrder
.filter((p) => groups[p]?.length)
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
.map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! }));
}
if (viewState.groupBy === "workspace") {
const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace");
@ -581,175 +533,16 @@ export function IssuesList({
title="Choose which issue columns stay visible"
/>
{/* Filter */}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}>
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>
{activeFilterCount > 0 && (
<span className="sm:hidden text-[10px] font-medium ml-0.5">{activeFilterCount}</span>
)}
{activeFilterCount > 0 && (
<X
className="h-3 w-3 ml-1 hidden sm:block"
onClick={(e) => {
e.stopPropagation();
updateView({ statuses: [], priorities: [], assignees: [], labels: [], projects: [] });
}}
/>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(480px,calc(100vw-2rem))] p-0">
<div className="p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filters</span>
{activeFilterCount > 0 && (
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => updateView({ statuses: [], priorities: [], assignees: [], labels: [] })}
>
Clear
</button>
)}
</div>
{/* Quick filters */}
<div className="space-y-1.5">
<span className="text-xs text-muted-foreground">Quick filters</span>
<div className="flex flex-wrap gap-1.5">
{quickFilterPresets.map((preset) => {
const isActive = arraysEqual(viewState.statuses, preset.statuses);
return (
<button
key={preset.label}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
isActive
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
}`}
onClick={() => updateView({ statuses: isActive ? [] : [...preset.statuses] })}
>
{preset.label}
</button>
);
})}
</div>
</div>
<div className="border-t border-border" />
{/* Multi-column filter sections */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
{/* Status */}
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Status</span>
<div className="space-y-0.5">
{statusOrder.map((s) => (
<label key={s} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.statuses.includes(s)}
onCheckedChange={() => updateView({ statuses: toggleInArray(viewState.statuses, s) })}
/>
<StatusIcon status={s} />
<span className="text-sm">{statusLabel(s)}</span>
</label>
))}
</div>
</div>
{/* Priority + Assignee stacked in right column */}
<div className="space-y-3">
{/* Priority */}
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Priority</span>
<div className="space-y-0.5">
{priorityOrder.map((p) => (
<label key={p} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.priorities.includes(p)}
onCheckedChange={() => updateView({ priorities: toggleInArray(viewState.priorities, p) })}
/>
<PriorityIcon priority={p} />
<span className="text-sm">{statusLabel(p)}</span>
</label>
))}
</div>
</div>
{/* Assignee */}
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes("__unassigned")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
/>
<span className="text-sm">No assignee</span>
</label>
{currentUserId && (
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes("__me")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
/>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">Me</span>
</label>
)}
{(agents ?? []).map((agent) => (
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes(agent.id)}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
/>
<span className="text-sm">{agent.name}</span>
</label>
))}
</div>
</div>
{labels && labels.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Labels</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{labels.map((label) => (
<label key={label.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.labels.includes(label.id)}
onCheckedChange={() => updateView({ labels: toggleInArray(viewState.labels, label.id) })}
/>
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: label.color }} />
<span className="text-sm">{label.name}</span>
</label>
))}
</div>
</div>
)}
{projects && projects.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Project</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{projects.map((project) => (
<label key={project.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.projects.includes(project.id)}
onCheckedChange={() => updateView({ projects: toggleInArray(viewState.projects, project.id) })}
/>
<span className="text-sm">{project.name}</span>
</label>
))}
</div>
</div>
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
<IssueFiltersPopover
state={viewState}
onChange={updateView}
activeFilterCount={activeFilterCount}
agents={agents}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
/>
{/* Sort (list view only) */}
{viewState.viewMode === "list" && (

View file

@ -3,7 +3,7 @@ import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusIcon } from "./StatusIcon";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
import { timeAgo } from "../lib/timeAgo";
interface IssuesQuicklookProps {
@ -36,6 +36,7 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
<Link
to={createIssueDetailPath(issue.identifier ?? issue.id)}
state={withIssueDetailHeaderSeed(null, issue)}
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
>
{issue.title}

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar";
@ -32,6 +32,11 @@ import {
DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings";
import {
resetNavigationScroll,
SIDEBAR_SCROLL_RESET_STATE,
shouldResetScrollOnNavigation,
} from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { cn } from "../lib/utils";
@ -66,9 +71,12 @@ export function Layout() {
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate();
const location = useLocation();
const navigationType = useNavigationType();
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const previousPathname = useRef<string | null>(null);
const mainContentRef = useRef<HTMLElement | null>(null);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const [shortcutsOpen, setShortcutsOpen] = useState(false);
@ -271,10 +279,24 @@ export function Layout() {
useEffect(() => {
if (typeof document === "undefined") return;
const mainContent = document.getElementById("main-content");
const mainContent = mainContentRef.current;
return scheduleMainContentFocus(mainContent);
}, [location.pathname]);
useEffect(() => {
const shouldResetScroll = shouldResetScrollOnNavigation({
previousPathname: previousPathname.current,
pathname: location.pathname,
navigationType,
state: location.state,
});
previousPathname.current = location.pathname;
if (!shouldResetScroll) return;
resetNavigationScroll(mainContentRef.current);
}, [location.pathname, navigationType]);
return (
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
@ -334,6 +356,7 @@ export function Layout() {
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to={instanceSettingsTarget}
state={SIDEBAR_SCROLL_RESET_STATE}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
@ -392,6 +415,7 @@ export function Layout() {
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to={instanceSettingsTarget}
state={SIDEBAR_SCROLL_RESET_STATE}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
@ -428,6 +452,7 @@ export function Layout() {
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
ref={mainContentRef}
tabIndex={-1}
className={cn(
"flex-1 p-4 outline-none md:p-6",

View file

@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { Link } from "@/lib/router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { formatDateTime } from "../lib/utils";
import { ExternalLink, Square } from "lucide-react";
@ -13,8 +13,6 @@ import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
interface LiveRunWidgetProps {
issueId: string;
companyId?: string | null;
liveRunsData?: LiveRunForIssue[];
activeRunData?: ActiveRunForIssue | null;
}
function toIsoString(value: string | Date | null | undefined): string | null {
@ -26,34 +24,24 @@ function isRunActive(status: string): boolean {
return status === "queued" || status === "running";
}
export function LiveRunWidget({
issueId,
companyId,
liveRunsData,
activeRunData,
}: LiveRunWidgetProps) {
export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
const queryClient = useQueryClient();
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>());
const shouldFetchLiveRuns = liveRunsData === undefined;
const shouldFetchActiveRun = activeRunData === undefined;
const { data: fetchedLiveRuns } = useQuery({
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
enabled: !!issueId && shouldFetchLiveRuns,
enabled: !!issueId,
refetchInterval: 3000,
});
const { data: fetchedActiveRun } = useQuery({
const { data: activeRun } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
enabled: !!issueId && shouldFetchActiveRun,
enabled: !!issueId,
refetchInterval: 3000,
});
const liveRuns = liveRunsData ?? fetchedLiveRuns;
const activeRun = activeRunData ?? fetchedActiveRun;
const runs = useMemo(() => {
const deduped = new Map<string, LiveRunForIssue>();
for (const run of liveRuns ?? []) {

View file

@ -3,7 +3,16 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor";
import { buildSkillMentionHref } from "@paperclipai/shared";
import {
computeMentionMenuPosition,
findClosestAutocompleteAnchor,
findMentionMatch,
isSameAutocompleteSession,
MarkdownEditor,
placeCaretAfterMentionAnchor,
shouldAcceptAutocompleteKey,
} from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
@ -213,4 +222,94 @@ describe("MarkdownEditor", () => {
it("still rejects slash commands once spaces are typed", () => {
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
});
it("does not treat Enter as skill autocomplete accept", () => {
expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false);
expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true);
expect(shouldAcceptAutocompleteKey("Enter", "mention")).toBe(true);
expect(shouldAcceptAutocompleteKey("Tab", "skill")).toBe(true);
});
it("keeps the same autocomplete session active while the slash query is unchanged", () => {
const textNode = document.createTextNode("/agent");
expect(isSameAutocompleteSession(
{
trigger: "skill",
marker: "/",
query: "agent",
textNode,
atPos: 0,
endPos: 6,
},
{
trigger: "skill",
marker: "/",
query: "agent",
textNode,
atPos: 0,
endPos: 6,
},
)).toBe(true);
expect(isSameAutocompleteSession(
{
trigger: "skill",
marker: "/",
query: "agent",
textNode,
atPos: 0,
endPos: 6,
},
{
trigger: "skill",
marker: "/",
query: "agent-browser",
textNode,
atPos: 0,
endPos: 14,
},
)).toBe(false);
});
it("finds skill anchors by mention metadata instead of visible text", () => {
const editable = document.createElement("div");
const skillLink = document.createElement("a");
skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser"));
skillLink.textContent = "/agent-browser ";
editable.appendChild(skillLink);
const found = findClosestAutocompleteAnchor(editable, {
id: "skill:skill-123",
kind: "skill",
skillId: "skill-123",
key: "agent-browser",
name: "Agent Browser",
slug: "agent-browser",
description: null,
href: buildSkillMentionHref("skill-123", "agent-browser"),
aliases: ["agent-browser", "Agent Browser"],
});
expect(found).toBe(skillLink);
});
it("places the caret after the mention's trailing space when present", () => {
const editable = document.createElement("div");
editable.contentEditable = "true";
document.body.appendChild(editable);
const skillLink = document.createElement("a");
skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser"));
skillLink.textContent = "/agent-browser";
const trailingSpace = document.createTextNode(" ");
editable.append(skillLink, trailingSpace);
expect(placeCaretAfterMentionAnchor(skillLink)).toBe(true);
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(trailingSpace);
expect(selection?.anchorOffset).toBe(1);
editable.remove();
});
});

View file

@ -297,6 +297,102 @@ function autocompleteMarkdown(option: AutocompleteOption): string {
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option);
}
export function shouldAcceptAutocompleteKey(
key: string,
trigger: MentionState["trigger"] | null,
skillEnterArmed = false,
): boolean {
if (key === "Tab") return true;
if (key !== "Enter") return false;
return trigger === "mention" || (trigger === "skill" && skillEnterArmed);
}
export function isSameAutocompleteSession(
left: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
right: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
): boolean {
if (!left || !right) return false;
return left.trigger === right.trigger
&& left.marker === right.marker
&& left.query === right.query
&& left.textNode === right.textNode
&& left.atPos === right.atPos
&& left.endPos === right.endPos;
}
function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string): boolean {
const parsed = parseMentionChipHref(href);
if (!parsed) return false;
if (option.kind === "skill") {
return parsed.kind === "skill" && parsed.skillId === option.skillId;
}
if (option.kind === "project" && option.projectId) {
return parsed.kind === "project" && parsed.projectId === option.projectId;
}
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
return parsed.kind === "agent" && parsed.agentId === agentId;
}
export function findClosestAutocompleteAnchor(
editable: HTMLElement,
option: AutocompleteOption,
origin?: Pick<MentionState, "left" | "top"> | null,
): HTMLAnchorElement | null {
const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => autocompleteOptionMatchesLink(option, link.getAttribute("href") ?? ""));
if (matchingMentions.length === 0) return null;
if (!origin) return matchingMentions[0] ?? null;
const containerRect = editable.getBoundingClientRect();
return matchingMentions.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
const leftA = rectA.left - containerRect.left;
const topA = rectA.top - containerRect.top;
const leftB = rectB.left - containerRect.left;
const topB = rectB.top - containerRect.top;
const distA = Math.hypot(leftA - origin.left, topA - origin.top);
const distB = Math.hypot(leftB - origin.left, topB - origin.top);
return distA - distB;
})[0] ?? null;
}
export function placeCaretAfterMentionAnchor(target: HTMLAnchorElement): boolean {
const selection = window.getSelection();
if (!selection) return false;
const range = document.createRange();
const nextSibling = target.nextSibling;
if (nextSibling?.nodeType === Node.TEXT_NODE) {
const text = nextSibling.textContent ?? "";
if (text.startsWith(" ")) {
range.setStart(nextSibling, 1);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
if (text.length > 0) {
range.setStart(nextSibling, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
}
range.setStartAfter(target);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
/** Replace the active autocomplete token in the markdown string with the selected token. */
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
const search = `${state.marker}${state.query}`;
@ -346,6 +442,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const skillEnterArmedRef = useRef(false);
const mentionActive = mentionState !== null && (
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
@ -509,6 +606,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const checkMention = useCallback(() => {
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
@ -519,6 +617,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
&& (!mentions || mentions.length === 0)
) {
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
@ -528,16 +627,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
&& slashCommands.length === 0
) {
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
const previous = mentionStateRef.current;
const sameSession = isSameAutocompleteSession(previous, result);
mentionStateRef.current = result;
if (result) {
setMentionState(result);
if (!sameSession) {
skillEnterArmedRef.current = false;
setMentionIndex(0);
} else {
setMentionState(null);
}
setMentionState(result);
}, [mentions, slashCommands.length]);
useEffect(() => {
@ -548,21 +649,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// also fires after typing (e.g. space to dismiss).
const onInput = () => requestAnimationFrame(checkMention);
let selRafId: number | null = null;
const onSelectionChange = () => {
if (selRafId !== null) return;
selRafId = requestAnimationFrame(() => {
selRafId = null;
checkMention();
});
};
document.addEventListener("selectionchange", onSelectionChange);
document.addEventListener("selectionchange", checkMention);
el?.addEventListener("input", onInput, true);
return () => {
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("selectionchange", checkMention);
el?.removeEventListener("input", onInput, true);
if (selRafId !== null) cancelAnimationFrame(selRafId);
};
}, [checkMention, mentions, slashCommands.length]);
@ -589,24 +680,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!editable) return;
decorateProjectMentions();
let rafId: number | null = null;
const observer = new MutationObserver(() => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
decorateProjectMentions();
});
decorateProjectMentions();
});
observer.observe(editable, {
subtree: true,
childList: true,
characterData: true,
});
return () => {
observer.disconnect();
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [decorateProjectMentions]);
return () => observer.disconnect();
}, [decorateProjectMentions, value]);
const selectMention = useCallback(
(option: AutocompleteOption) => {
@ -623,65 +706,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
onChange(next);
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!(editable instanceof HTMLElement)) return;
decorateProjectMentions();
editable.focus();
const restoreSelection = (attemptsRemaining: number) => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!(editable instanceof HTMLElement)) return;
const mentionHref = option.kind === "skill"
? option.href
: option.kind === "project" && option.projectId
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
: buildAgentMentionHref(
option.agentId ?? option.id.replace(/^agent:/, ""),
option.agentIcon ?? null,
);
const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`;
const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => {
const href = link.getAttribute("href") ?? "";
return href === mentionHref && link.textContent === expectedLabel;
});
const containerRect = containerRef.current?.getBoundingClientRect();
const target = matchingMentions.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
const leftA = containerRect ? rectA.left - containerRect.left : rectA.left;
const topA = containerRect ? rectA.top - containerRect.top : rectA.top;
const leftB = containerRect ? rectB.left - containerRect.left : rectB.left;
const topB = containerRect ? rectB.top - containerRect.top : rectB.top;
const distA = Math.hypot(leftA - state.left, topA - state.top);
const distB = Math.hypot(leftB - state.left, topB - state.top);
return distA - distB;
})[0] ?? null;
if (!target) return;
decorateProjectMentions();
editable.focus();
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
const nextSibling = target.nextSibling;
if (nextSibling?.nodeType === Node.TEXT_NODE) {
const text = nextSibling.textContent ?? "";
if (text.startsWith(" ")) {
range.setStart(nextSibling, 1);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return;
}
const target = findClosestAutocompleteAnchor(editable, option, state);
if (!target) {
if (attemptsRemaining > 0) {
requestAnimationFrame(() => restoreSelection(attemptsRemaining - 1));
}
return;
}
range.setStartAfter(target);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
});
});
placeCaretAfterMentionAnchor(target);
};
requestAnimationFrame(() => restoreSelection(4));
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
},
[decorateProjectMentions, onChange],
@ -737,6 +783,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
if (mentionActive) {
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
@ -745,6 +792,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
e.preventDefault();
e.stopPropagation();
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
@ -753,16 +801,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
if (e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
setMentionIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
if (
shouldAcceptAutocompleteKey(
e.key,
mentionStateRef.current?.trigger ?? null,
skillEnterArmedRef.current,
)
) {
e.preventDefault();
e.stopPropagation();
selectMention(filteredMentions[mentionIndex]);
@ -865,7 +921,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
e.preventDefault(); // prevent blur
selectMention(option);
}}
onMouseEnter={() => setMentionIndex(i)}
onMouseEnter={() => {
if (mentionStateRef.current?.trigger === "skill") {
skillEnterArmedRef.current = true;
}
setMentionIndex(i);
}}
>
{option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />

View file

@ -9,6 +9,7 @@ import {
} from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn } from "../lib/utils";
import { useInboxBadge } from "../hooks/useInboxBadge";
@ -92,6 +93,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
<NavLink
key={item.label}
to={item.to}
state={SIDEBAR_SCROLL_RESET_STATE}
className={({ isActive }) =>
cn(
"relative flex min-w-0 flex-col items-center justify-center gap-1 rounded-md text-[10px] font-medium transition-colors",

View file

@ -384,6 +384,24 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("keeps the mobile dialog bounded with an internal flexible scroll region", async () => {
const { root } = renderDialog(container);
await flush();
const dialogContent = Array.from(container.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"),
);
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
expect(dialogContent?.className).toContain("overflow-hidden");
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
const descriptionScrollRegion = descriptionInput?.parentElement?.parentElement;
expect(descriptionScrollRegion?.className).toContain("flex-1");
expect(descriptionScrollRegion?.className).toContain("overflow-y-auto");
act(() => root.unmount());
});
it("warns when a sub-issue stops matching the parent workspace", async () => {
mockProjectsApi.list.mockResolvedValue([
{
@ -430,6 +448,7 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container);
await flush();
await flush();
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");

View file

@ -946,9 +946,9 @@ export function NewIssueDialog() {
showCloseButton={false}
aria-describedby={undefined}
className={cn(
"p-0 gap-0 flex flex-col max-h-[calc(100dvh-2rem)]",
"flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-auto",
expanded
? "sm:max-w-2xl h-[calc(100dvh-2rem)]"
? "sm:max-w-2xl sm:h-[calc(100dvh-2rem)]"
: "sm:max-w-lg"
)}
onKeyDown={handleKeyDown}
@ -1452,7 +1452,7 @@ export function NewIssueDialog() {
{/* Description */}
<div
className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}
className="min-h-0 flex-1 overflow-y-auto border-t border-border/60 px-4 pb-2 pt-3"
onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}

View file

@ -115,4 +115,77 @@ describe("useLiveRunTranscripts", () => {
expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]);
container.remove();
});
it("treats stored run output as available before transcript chunks finish loading", async () => {
let latestHasOutput = false;
function Harness() {
const { hasOutputForRun } = useLiveRunTranscripts({
companyId: "company-1",
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", hasStoredOutput: true }],
});
latestHasOutput = hasOutputForRun("run-1");
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(latestHasOutput).toBe(true);
act(() => {
root.unmount();
});
container.remove();
});
it("reports initial hydration until the first persisted-log read completes", async () => {
let latestIsInitialHydrating = false;
type RunLogResult = { runId: string; store: string; logRef: string; content: string; nextOffset: number };
let resolveLog: ((value: RunLogResult | PromiseLike<RunLogResult>) => void) | null = null;
logMock.mockImplementationOnce(
() =>
new Promise<RunLogResult>((resolve) => {
resolveLog = resolve;
}),
);
function Harness() {
const { isInitialHydrating } = useLiveRunTranscripts({
companyId: "company-1",
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local" }],
});
latestIsInitialHydrating = isInitialHydrating;
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(latestIsInitialHydrating).toBe(true);
await act(async () => {
resolveLog?.({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 });
await Promise.resolve();
});
expect(latestIsInitialHydrating).toBe(false);
act(() => {
root.unmount();
});
container.remove();
});
});

View file

@ -13,6 +13,7 @@ export interface RunTranscriptSource {
id: string;
status: string;
adapterType: string;
hasStoredOutput?: boolean;
}
interface UseLiveRunTranscriptsOptions {
@ -70,7 +71,17 @@ export function useLiveRunTranscripts({
companyId,
maxChunksPerRun = 200,
}: UseLiveRunTranscriptsOptions) {
const runsKey = useMemo(
() =>
runs
.map((run) => `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}`)
.sort((a, b) => a.localeCompare(b))
.join(","),
[runs],
);
const normalizedRuns = useMemo(() => runs.map((run) => ({ ...run })), [runsKey]);
const [chunksByRun, setChunksByRun] = useState<Map<string, RunLogChunk[]>>(new Map());
const [hydratedRunIds, setHydratedRunIds] = useState<Set<string>>(new Set());
const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
@ -84,14 +95,14 @@ export function useLiveRunTranscripts({
queryFn: () => instanceSettingsApi.getGeneral(),
});
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
const runById = useMemo(() => new Map(normalizedRuns.map((run) => [run.id, run])), [normalizedRuns]);
const activeRunIds = useMemo(
() => new Set(runs.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
[runs],
() => new Set(normalizedRuns.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
[normalizedRuns],
);
const runIdsKey = useMemo(
() => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
[runs],
() => normalizedRuns.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
[normalizedRuns],
);
const appendChunks = (runId: string, chunks: Array<RunLogChunk & { dedupeKey: string }>) => {
@ -118,7 +129,7 @@ export function useLiveRunTranscripts({
};
useEffect(() => {
const knownRunIds = new Set(runs.map((run) => run.id));
const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
setChunksByRun((prev) => {
const next = new Map<string, RunLogChunk[]>();
for (const [runId, chunks] of prev) {
@ -128,6 +139,15 @@ export function useLiveRunTranscripts({
}
return next.size === prev.size ? prev : next;
});
setHydratedRunIds((prev) => {
const next = new Set<string>();
for (const runId of prev) {
if (knownRunIds.has(runId)) {
next.add(runId);
}
}
return next.size === prev.size ? prev : next;
});
for (const key of pendingLogRowsByRunRef.current.keys()) {
const runId = key.replace(/:records$/, "");
@ -140,10 +160,10 @@ export function useLiveRunTranscripts({
logOffsetByRunRef.current.delete(runId);
}
}
}, [runs]);
}, [normalizedRuns]);
useEffect(() => {
if (runs.length === 0) return;
if (normalizedRuns.length === 0) return;
let cancelled = false;
@ -164,15 +184,24 @@ export function useLiveRunTranscripts({
}
} catch {
// Ignore log read errors while output is initializing.
} finally {
if (!cancelled) {
setHydratedRunIds((prev) => {
if (prev.has(run.id)) return prev;
const next = new Set(prev);
next.add(run.id);
return next;
});
}
}
};
const readAll = async () => {
await Promise.all(runs.map((run) => readRunLog(run)));
await Promise.all(normalizedRuns.map((run) => readRunLog(run)));
};
void readAll();
const activeRuns = runs.filter((run) => !isTerminalStatus(run.status));
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
const interval = activeRuns.length > 0
? window.setInterval(() => {
void Promise.all(activeRuns.map((run) => readRunLog(run)));
@ -183,7 +212,7 @@ export function useLiveRunTranscripts({
cancelled = true;
if (interval !== null) window.clearInterval(interval);
};
}, [runIdsKey, runs]);
}, [normalizedRuns, runIdsKey]);
useEffect(() => {
if (!companyId || activeRunIds.size === 0) return;
@ -298,7 +327,7 @@ export function useLiveRunTranscripts({
const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
for (const run of runs) {
for (const run of normalizedRuns) {
const adapter = getUIAdapter(run.adapterType);
next.set(
run.id,
@ -308,12 +337,13 @@ export function useLiveRunTranscripts({
);
}
return next;
}, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]);
}, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);
return {
transcriptByRun,
isInitialHydrating: normalizedRuns.some((run) => !hydratedRunIds.has(run.id)),
hasOutputForRun(runId: string) {
return (chunksByRun.get(runId)?.length ?? 0) > 0;
return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
},
};
}