mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
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:
commit
0e87fdbe35
50 changed files with 2860 additions and 1206 deletions
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = ``;
|
||||
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 = ``;
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
232
ui/src/components/IssueFiltersPopover.tsx
Normal file
232
ui/src/components/IssueFiltersPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? []) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue