mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
feat(ui): polish issue and inbox workflows
This commit is contained in:
1 parent
0e87fdbe35
commit
05741d5291
45 files changed
+1926
-265
No files matched your search
@@ -70,4 +70,116 @@ describe("buildTranscript", () => {
|
||||
expect(first).toEqual([]);
|
||||
expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]);
|
||||
});
|
||||
|
||||
it("converts parser failures into transcript error entries and keeps going", () => {
|
||||
const entries = buildTranscript(
|
||||
[
|
||||
{ ts, stream: "stdout", chunk: "ok\nexplode\nlater\n" },
|
||||
],
|
||||
(line, entryTs) => {
|
||||
if (line === "explode") {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return [{ kind: "stdout", ts: entryTs, text: line }];
|
||||
},
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ kind: "stdout", ts, text: "ok" },
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Chat transcript error: boom. Falling back for line: explode",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "transcript_parse_error",
|
||||
isError: true,
|
||||
errors: [],
|
||||
},
|
||||
{ kind: "stdout", ts, text: "later" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resets stateful parsers after a failure before parsing later lines", () => {
|
||||
const statefulAdapter: UIAdapterModule = {
|
||||
type: "stateful_test",
|
||||
label: "Stateful Test",
|
||||
parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }],
|
||||
createStdoutParser: () => {
|
||||
let pending: string | null = null;
|
||||
return {
|
||||
parseLine: (line, entryTs) => {
|
||||
if (line.startsWith("begin:")) {
|
||||
pending = line.slice("begin:".length);
|
||||
return [];
|
||||
}
|
||||
if (line === "explode") {
|
||||
throw new Error(`bad state:${pending ?? "none"}`);
|
||||
}
|
||||
if (line === "finish" && pending) {
|
||||
const text = `completed:${pending}`;
|
||||
pending = null;
|
||||
return [{ kind: "stdout", ts: entryTs, text }];
|
||||
}
|
||||
return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }];
|
||||
},
|
||||
reset: () => {
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
ConfigFields: () => null,
|
||||
buildAdapterConfig: () => ({}),
|
||||
};
|
||||
|
||||
const entries = buildTranscript(
|
||||
[{ ts, stream: "stdout", chunk: "begin:task-a\nexplode\nfinish\n" }],
|
||||
statefulAdapter,
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Chat transcript error: bad state:task-a. Falling back for line: explode",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "transcript_parse_error",
|
||||
isError: true,
|
||||
errors: [],
|
||||
},
|
||||
{ kind: "stdout", ts, text: "literal:finish" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles trailing buffered parser failures without throwing", () => {
|
||||
const entries = buildTranscript(
|
||||
[{ ts, stream: "stdout", chunk: "explode" }],
|
||||
(line, entryTs) => {
|
||||
if (line === "explode") {
|
||||
throw new Error("trailing boom");
|
||||
}
|
||||
return [{ kind: "stdout", ts: entryTs, text: line }];
|
||||
},
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Chat transcript error: trailing boom. Falling back for line: explode",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "transcript_parse_error",
|
||||
isError: true,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "
|
||||
|
||||
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
|
||||
type RedactionOptions = { enabled: boolean };
|
||||
|
||||
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
|
||||
if (typeof source === "function") {
|
||||
@@ -33,6 +34,66 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
|
||||
}
|
||||
}
|
||||
|
||||
function truncateTranscriptLine(line: string, maxLength = 160) {
|
||||
if (line.length <= maxLength) return line;
|
||||
return `${line.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function formatTranscriptParserError(error: unknown) {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function createTranscriptParseErrorEntry(
|
||||
line: string,
|
||||
ts: string,
|
||||
error: unknown,
|
||||
redactionOptions: RedactionOptions,
|
||||
): TranscriptEntry {
|
||||
const errorText = formatTranscriptParserError(error) || "unknown parser error";
|
||||
const preview = truncateTranscriptLine(line);
|
||||
return {
|
||||
kind: "result",
|
||||
ts,
|
||||
text: redactHomePathUserSegments(
|
||||
`Chat transcript error: ${errorText}. Falling back for line: ${preview}`,
|
||||
redactionOptions,
|
||||
),
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "transcript_parse_error",
|
||||
isError: true,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function appendParsedTranscriptLine(args: {
|
||||
entries: TranscriptEntry[];
|
||||
line: string;
|
||||
ts: string;
|
||||
parseLine: (line: string, ts: string) => TranscriptEntry[];
|
||||
reset: (() => void) | null;
|
||||
redactionOptions: RedactionOptions;
|
||||
}) {
|
||||
const { entries, line, ts, parseLine, reset, redactionOptions } = args;
|
||||
try {
|
||||
appendTranscriptEntries(
|
||||
entries,
|
||||
parseLine(line, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)),
|
||||
);
|
||||
} catch (error) {
|
||||
reset?.();
|
||||
appendTranscriptEntry(entries, createTranscriptParseErrorEntry(line, ts, error, redactionOptions));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTranscript(
|
||||
chunks: RunLogChunk[],
|
||||
parserSource: StdoutLineParser | TranscriptParserSource,
|
||||
@@ -59,14 +120,28 @@ export function buildTranscript(
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
appendParsedTranscriptLine({
|
||||
entries,
|
||||
line: trimmed,
|
||||
ts: chunk.ts,
|
||||
parseLine,
|
||||
reset,
|
||||
redactionOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = stdoutBuffer.trim();
|
||||
if (trailing) {
|
||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||
appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
appendParsedTranscriptLine({
|
||||
entries,
|
||||
line: trailing,
|
||||
ts,
|
||||
parseLine,
|
||||
reset,
|
||||
redactionOptions,
|
||||
});
|
||||
}
|
||||
|
||||
reset?.();
|
||||
|
||||
@@ -369,7 +369,7 @@ function CommentCard({
|
||||
<CopyMarkdownButton text={comment.body} />
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
|
||||
{companyId && !isPending ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<PluginSlotOutlet
|
||||
|
||||
@@ -424,7 +424,12 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||
const { onImageClick } = useContext(IssueChatCtx);
|
||||
return (
|
||||
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined} onImageClick={onImageClick}>
|
||||
<MarkdownBody
|
||||
className="text-sm leading-6"
|
||||
style={recessed ? { opacity: 0.55 } : undefined}
|
||||
softBreaks
|
||||
onImageClick={onImageClick}
|
||||
>
|
||||
{text}
|
||||
</MarkdownBody>
|
||||
);
|
||||
|
||||
@@ -50,12 +50,12 @@ export function issueActivityText(issue: Issue): string {
|
||||
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||
return columns
|
||||
.map((column) => {
|
||||
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||
if (column === "parent") return "minmax(5rem, 7rem)";
|
||||
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||
return "minmax(4rem, 5.5rem)";
|
||||
if (column === "assignee") return "minmax(6rem, 8rem)";
|
||||
if (column === "project") return "minmax(4.5rem, 7rem)";
|
||||
if (column === "workspace") return "minmax(6rem, 9rem)";
|
||||
if (column === "parent") return "minmax(3.5rem, 5.5rem)";
|
||||
if (column === "labels") return "minmax(3rem, 6rem)";
|
||||
return "minmax(3.5rem, 4.5rem)";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
@@ -66,24 +66,27 @@ export function IssueColumnPicker({
|
||||
onToggleColumn,
|
||||
onResetColumns,
|
||||
title,
|
||||
iconOnly = false,
|
||||
}: {
|
||||
availableColumns: InboxIssueColumn[];
|
||||
visibleColumnSet: ReadonlySet<InboxIssueColumn>;
|
||||
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
|
||||
onResetColumns: () => void;
|
||||
title: string;
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"
|
||||
variant={iconOnly ? "outline" : "ghost"}
|
||||
size={iconOnly ? "icon" : "sm"}
|
||||
className={iconOnly ? "h-8 w-8 shrink-0" : "hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"}
|
||||
title="Columns"
|
||||
>
|
||||
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||
Columns
|
||||
<Columns3 className={iconOnly ? "h-3.5 w-3.5" : "mr-1 h-3.5 w-3.5"} />
|
||||
{!iconOnly && "Columns"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
||||
@@ -276,20 +279,22 @@ export function InboxIssueTrailingColumns({
|
||||
if (column === "labels") {
|
||||
if ((issue.labels ?? []).length > 0) {
|
||||
return (
|
||||
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
|
||||
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex min-w-0 max-w-full items-center font-medium"
|
||||
className="inline-flex min-w-0 max-w-full shrink-0 items-center rounded-full border px-1.5 py-0 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
color: pickTextColorForPillBg(label.color, 0.12),
|
||||
backgroundColor: `${label.color}1f`,
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 2 ? (
|
||||
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||
+{(issue.labels ?? []).length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -69,7 +69,7 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||
}
|
||||
|
||||
function renderBody(body: string, className?: string) {
|
||||
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||
return <MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>;
|
||||
}
|
||||
|
||||
function isPlanKey(key: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Filter, X, User, HardDrive } from "lucide-react";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import {
|
||||
@@ -31,6 +31,11 @@ type LabelOption = {
|
||||
color: string;
|
||||
};
|
||||
|
||||
type WorkspaceOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function IssueFiltersPopover({
|
||||
state,
|
||||
onChange,
|
||||
@@ -41,6 +46,8 @@ export function IssueFiltersPopover({
|
||||
currentUserId,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
buttonVariant = "ghost",
|
||||
iconOnly = false,
|
||||
workspaces,
|
||||
}: {
|
||||
state: IssueFilterState;
|
||||
onChange: (patch: Partial<IssueFilterState>) => void;
|
||||
@@ -51,15 +58,18 @@ export function IssueFiltersPopover({
|
||||
currentUserId?: string | null;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
buttonVariant?: "ghost" | "outline";
|
||||
iconOnly?: boolean;
|
||||
workspaces?: WorkspaceOption[];
|
||||
}) {
|
||||
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 ? (
|
||||
<Button variant={buttonVariant} size={iconOnly ? "icon" : "sm"} className={`text-xs ${iconOnly ? "relative h-8 w-8 shrink-0" : ""} ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`} title={iconOnly ? (activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter") : undefined}>
|
||||
<Filter className={iconOnly ? "h-3.5 w-3.5" : "h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1"} />
|
||||
{!iconOnly && <span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>}
|
||||
{!iconOnly && activeFilterCount > 0 ? <span className="ml-0.5 text-[10px] font-medium sm:hidden">{activeFilterCount}</span> : null}
|
||||
{iconOnly && activeFilterCount > 0 ? <span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-blue-600 text-[9px] font-bold text-white">{activeFilterCount}</span> : null}
|
||||
{!iconOnly && activeFilterCount > 0 ? (
|
||||
<X
|
||||
className="ml-1 hidden h-3 w-3 sm:block"
|
||||
onClick={(event) => {
|
||||
@@ -211,6 +221,24 @@ export function IssueFiltersPopover({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{workspaces && workspaces.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Workspace</span>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
{workspaces.map((workspace) => (
|
||||
<label key={workspace.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.workspaces.includes(workspace.id)}
|
||||
onCheckedChange={() => onChange({ workspaces: toggleIssueFilterValue(state.workspaces, workspace.id) })}
|
||||
/>
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">{workspace.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{enableRoutineVisibilityFilter ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Visibility</span>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import * as RouterDom from "react-router-dom";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
|
||||
function summarizeIssueDescription(description: string | null | undefined) {
|
||||
if (!description) return null;
|
||||
const summary = description
|
||||
.replace(/!\[[^\]]*]\([^)]+\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[#>*_`~-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!summary) return null;
|
||||
return summary.length > 180 ? `${summary.slice(0, 177).trimEnd()}...` : summary;
|
||||
}
|
||||
|
||||
export function IssueQuicklookCard({
|
||||
issue,
|
||||
linkTo,
|
||||
linkState,
|
||||
compact = false,
|
||||
}: {
|
||||
issue: Issue;
|
||||
linkTo: RouterDom.To;
|
||||
linkState?: unknown;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", compact && "space-y-1.5")}>
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
|
||||
<RouterDom.Link
|
||||
to={linkTo}
|
||||
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
|
||||
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
|
||||
>
|
||||
{issue.title}
|
||||
</RouterDom.Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
<span>·</span>
|
||||
<span>{issue.status.replace(/_/g, " ")}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(new Date(issue.updatedAt))}</span>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:4] overflow-hidden">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const IssueLinkQuicklook = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<typeof RouterDom.Link> & { issuePathId: string }
|
||||
>(function IssueLinkQuicklookImpl(
|
||||
{
|
||||
issuePathId,
|
||||
to,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issuePathId),
|
||||
queryFn: () => issuesApi.get(issuePathId),
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const detailPath = createIssueDetailPath(issuePathId);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<RouterDom.Link
|
||||
ref={ref}
|
||||
to={to}
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
setOpen(false);
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RouterDom.Link>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-3"
|
||||
side="top"
|
||||
align="start"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{data ? (
|
||||
<IssueQuicklookCard issue={data} linkTo={detailPath} compact />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-24 rounded bg-accent/50" />
|
||||
<div className="h-4 w-full rounded bg-accent/40" />
|
||||
<div className="h-4 w-3/4 rounded bg-accent/30" />
|
||||
{!isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Unable to load issue preview.</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const mockProjectsApi = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listLabels: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -193,6 +194,7 @@ describe("IssueProperties", () => {
|
||||
document.body.appendChild(container);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||
});
|
||||
@@ -227,6 +229,92 @@ describe("IssueProperties", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("allows setting and clearing a parent issue from the properties pane", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }),
|
||||
]);
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue(),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const parentTrigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("No parent"));
|
||||
expect(parentTrigger).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const candidateButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
|
||||
expect(candidateButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" });
|
||||
|
||||
onUpdate.mockClear();
|
||||
const rerenderedIssue = createIssue({
|
||||
parentId: "issue-2",
|
||||
ancestors: [
|
||||
{
|
||||
id: "issue-2",
|
||||
identifier: "PAP-2",
|
||||
title: "Candidate parent",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
project: null,
|
||||
goal: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
|
||||
const rerenderedRoot = renderProperties(container, {
|
||||
issue: rerenderedIssue,
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
|
||||
expect(selectedParentTrigger).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const clearParentButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("No parent"));
|
||||
expect(clearParentButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ parentId: null });
|
||||
|
||||
act(() => rerenderedRoot.unmount());
|
||||
});
|
||||
|
||||
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
|
||||
@@ -167,6 +167,8 @@ export function IssueProperties({
|
||||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [blockedByOpen, setBlockedByOpen] = useState(false);
|
||||
const [blockedBySearch, setBlockedBySearch] = useState("");
|
||||
const [parentOpen, setParentOpen] = useState(false);
|
||||
const [parentSearch, setParentSearch] = useState("");
|
||||
const [reviewersOpen, setReviewersOpen] = useState(false);
|
||||
const [reviewerSearch, setReviewerSearch] = useState("");
|
||||
const [approversOpen, setApproversOpen] = useState(false);
|
||||
@@ -212,7 +214,7 @@ export function IssueProperties({
|
||||
const { data: allIssues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(companyId!),
|
||||
queryFn: () => issuesApi.list(companyId!),
|
||||
enabled: !!companyId && blockedByOpen,
|
||||
enabled: !!companyId && (blockedByOpen || parentOpen),
|
||||
});
|
||||
|
||||
const createLabel = useMutation({
|
||||
@@ -685,6 +687,100 @@ export function IssueProperties({
|
||||
);
|
||||
|
||||
const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? [];
|
||||
const descendantIssueIds = useMemo(() => {
|
||||
if (!allIssues?.length) return new Set<string>();
|
||||
const childrenByParentId = new Map<string, string[]>();
|
||||
for (const candidate of allIssues) {
|
||||
if (!candidate.parentId) continue;
|
||||
const children = childrenByParentId.get(candidate.parentId) ?? [];
|
||||
children.push(candidate.id);
|
||||
childrenByParentId.set(candidate.parentId, children);
|
||||
}
|
||||
|
||||
const descendants = new Set<string>();
|
||||
const stack = [...(childrenByParentId.get(issue.id) ?? [])];
|
||||
while (stack.length > 0) {
|
||||
const candidateId = stack.pop();
|
||||
if (!candidateId || descendants.has(candidateId)) continue;
|
||||
descendants.add(candidateId);
|
||||
stack.push(...(childrenByParentId.get(candidateId) ?? []));
|
||||
}
|
||||
return descendants;
|
||||
}, [allIssues, issue.id]);
|
||||
const currentParentIssue = useMemo(() => {
|
||||
if (!issue.parentId) return null;
|
||||
return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null;
|
||||
}, [allIssues, issue.parentId]);
|
||||
const parentTrigger = issue.parentId ? (
|
||||
<span className="text-sm truncate">
|
||||
{issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier
|
||||
? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} `
|
||||
: ""}
|
||||
{issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No parent</span>
|
||||
);
|
||||
const parentOptions = (allIssues ?? [])
|
||||
.filter((candidate) => candidate.id !== issue.id)
|
||||
.filter((candidate) => !descendantIssueIds.has(candidate.id))
|
||||
.filter((candidate) => {
|
||||
if (!parentSearch.trim()) return true;
|
||||
const query = parentSearch.toLowerCase();
|
||||
return (
|
||||
(candidate.identifier ?? "").toLowerCase().includes(query) ||
|
||||
candidate.title.toLowerCase().includes(query)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
|
||||
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
const parentContent = (
|
||||
<>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search issues..."
|
||||
value={parentSearch}
|
||||
onChange={(e) => setParentSearch(e.target.value)}
|
||||
autoFocus={!inline}
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!issue.parentId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ parentId: null });
|
||||
setParentOpen(false);
|
||||
}}
|
||||
>
|
||||
No parent
|
||||
</button>
|
||||
{parentOptions.map((candidate) => (
|
||||
<button
|
||||
key={candidate.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs rounded hover:bg-accent/50",
|
||||
candidate.id === issue.parentId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ parentId: candidate.id });
|
||||
setParentOpen(false);
|
||||
}}
|
||||
>
|
||||
<StatusIcon status={candidate.status} />
|
||||
<span className="truncate">
|
||||
{candidate.identifier ? `${candidate.identifier} ` : ""}
|
||||
{candidate.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const blockedByTrigger = blockedByIds.length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap min-w-0">
|
||||
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => (
|
||||
@@ -838,6 +934,30 @@ export function IssueProperties({
|
||||
{projectContent}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Parent"
|
||||
open={parentOpen}
|
||||
onOpenChange={(open) => {
|
||||
setParentOpen(open);
|
||||
if (!open) setParentSearch("");
|
||||
}}
|
||||
triggerContent={parentTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-72"
|
||||
extra={issue.parentId ? (
|
||||
<Link
|
||||
to={`/issues/${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier ?? issue.parentId}`}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : undefined}
|
||||
>
|
||||
{parentContent}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Blocked by"
|
||||
@@ -939,16 +1059,6 @@ export function IssueProperties({
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.requestDepth > 0 && (
|
||||
<PropertyRow label="Depth">
|
||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||
|
||||
@@ -7,8 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueRow } from "./IssueRow";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => (
|
||||
<a className={className} {...props}>{children}</a>
|
||||
Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => (
|
||||
<a
|
||||
className={className}
|
||||
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -135,6 +141,22 @@ describe("IssueRow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opts issue quicklook out for dense inbox rows", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={createIssue()} />);
|
||||
});
|
||||
|
||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute("data-disable-issue-quicklook")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders titleSuffix inline after the issue title", () => {
|
||||
const root = createRoot(container);
|
||||
const issue = createIssue({ title: "Parent task" });
|
||||
|
||||
@@ -58,6 +58,7 @@ export function IssueRow({
|
||||
<Link
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
state={detailState}
|
||||
disableIssueQuicklook
|
||||
data-inbox-issue-link
|
||||
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
|
||||
className={cn(
|
||||
|
||||
@@ -297,7 +297,10 @@ describe("IssuesList", () => {
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Columns");
|
||||
const columnsButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.getAttribute("title") === "Columns",
|
||||
);
|
||||
expect(columnsButton).toBeDefined();
|
||||
expect(container.textContent).toContain("PAP-9");
|
||||
expect(container.textContent).toContain("Agent One");
|
||||
expect(container.textContent).not.toContain("Updated");
|
||||
@@ -341,7 +344,7 @@ describe("IssuesList", () => {
|
||||
|
||||
await act(async () => {
|
||||
const filterButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Filter"),
|
||||
(button) => button.getAttribute("title") === "Filter",
|
||||
);
|
||||
filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
@@ -370,4 +373,75 @@ describe("IssuesList", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("blurs the search input on Enter without clearing the query", async () => {
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
initialSearch="bug"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
input?.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
expect(input.value).toBe("bug");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("blurs the search input on Escape once the field is empty", async () => {
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
initialSearch=""
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
input?.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Escape",
|
||||
bubbles: true,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@ import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import {
|
||||
@@ -170,9 +174,27 @@ function IssueSearchInput({
|
||||
onChange={(e) => {
|
||||
setDraftValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (shouldBlurPageSearchOnEnter({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBlurPageSearchOnEscape({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
currentValue: e.currentTarget.value,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Search issues..."
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search issues"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -531,6 +553,7 @@ export function IssuesList({
|
||||
onToggleColumn={toggleIssueColumn}
|
||||
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||
title="Choose which issue columns stay visible"
|
||||
iconOnly
|
||||
/>
|
||||
|
||||
<IssueFiltersPopover
|
||||
@@ -542,15 +565,16 @@ export function IssuesList({
|
||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace" && w.status === "active").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||
/>
|
||||
|
||||
{/* Sort (list view only) */}
|
||||
{viewState.viewMode === "list" && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Sort</span>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Sort">
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 p-0">
|
||||
@@ -592,9 +616,8 @@ export function IssuesList({
|
||||
{viewState.viewMode === "list" && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Group</span>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Group">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState } from "react";
|
||||
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, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { IssueQuicklookCard } from "./IssueLinkQuicklook";
|
||||
|
||||
interface IssuesQuicklookProps {
|
||||
issue: Issue;
|
||||
@@ -24,32 +22,18 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-64 p-3"
|
||||
className="w-72 p-3"
|
||||
side="top"
|
||||
align="start"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<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}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
<span>·</span>
|
||||
<span>{issue.status.replace(/_/g, " ")}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(new Date(issue.updatedAt))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<IssueQuicklookCard
|
||||
issue={issue}
|
||||
linkTo={createIssueDetailPath(issue.identifier ?? issue.id)}
|
||||
linkState={withIssueDetailHeaderSeed(null, issue)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -148,6 +148,7 @@ function KanbanCard({
|
||||
>
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
disableIssueQuicklook
|
||||
className="block no-underline text-inherit"
|
||||
onClick={(e) => {
|
||||
// Prevent navigation during drag
|
||||
|
||||
@@ -34,6 +34,7 @@ const sections: ShortcutSection[] = [
|
||||
{
|
||||
title: "Global",
|
||||
shortcuts: [
|
||||
{ keys: ["/"], label: "Search current page or quick search" },
|
||||
{ keys: ["c"], label: "New issue" },
|
||||
{ keys: ["["], label: "Toggle sidebar" },
|
||||
{ keys: ["]"], label: "Toggle panel" },
|
||||
|
||||
@@ -154,12 +154,21 @@ export function Layout() {
|
||||
]);
|
||||
|
||||
const togglePanel = togglePanelVisible;
|
||||
const openSearch = useCallback(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "k",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useCompanyPageMemory();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: keyboardShortcutsEnabled,
|
||||
onNewIssue: () => openNewIssue(),
|
||||
onSearch: openSearch,
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onTogglePanel: togglePanel,
|
||||
onShowShortcuts: () => setShortcutsOpen(true),
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
describe("MarkdownBody", () => {
|
||||
it("renders markdown images without a resolver", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
@@ -48,4 +53,66 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain('href="/skills/skill-789"');
|
||||
expect(html).toContain('data-mention-kind="skill"');
|
||||
});
|
||||
|
||||
it("uses soft-break styling by default", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>
|
||||
{"First line\nSecond line"}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("First line<br/>");
|
||||
expect(html).toContain("Second line");
|
||||
});
|
||||
|
||||
it("can opt out of soft-break styling", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody softBreaks={false}>
|
||||
{"First line\nSecond line"}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("<br/>");
|
||||
});
|
||||
|
||||
it("does not inject extra line-break nodes into nested lists", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>
|
||||
{"1. Parent item\n - child a\n - child b\n\n2. Second item"}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("[&_p]:whitespace-pre-line");
|
||||
expect(html).not.toContain("Parent item<br/>");
|
||||
expect(html).toContain("<ol>");
|
||||
expect(html).toContain("<ul>");
|
||||
});
|
||||
|
||||
it("linkifies bare issue identifiers in markdown text", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{"Depends on PAP-1271 for the hover state."}</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/issues/PAP-1271"');
|
||||
expect(html).toContain(">PAP-1271<");
|
||||
});
|
||||
|
||||
it("rewrites full issue URLs to internal issue links", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{"See http://localhost:3100/PAP/issues/PAP-1179."}</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/issues/PAP-1179"');
|
||||
expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<");
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,15 @@ import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { Link } from "@/lib/router";
|
||||
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
|
||||
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
softBreaks?: boolean;
|
||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
@@ -94,8 +98,11 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
|
||||
export function MarkdownBody({ children, className, style, softBreaks = true, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
const remarkPlugins = softBreaks
|
||||
? [remarkGfm, remarkLinkIssueReferences, remarkSoftBreaks]
|
||||
: [remarkGfm, remarkLinkIssueReferences];
|
||||
const components: Components = {
|
||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||
const mermaidSource = extractMermaidSource(preChildren);
|
||||
@@ -105,6 +112,15 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm
|
||||
return <pre {...preProps}>{preChildren}</pre>;
|
||||
},
|
||||
a: ({ href, children: linkChildren }) => {
|
||||
const issueRef = parseIssueReferenceFromHref(href);
|
||||
if (issueRef) {
|
||||
return (
|
||||
<Link to={issueRef.href}>
|
||||
{linkChildren}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = href ? parseMentionChipHref(href) : null;
|
||||
if (parsed) {
|
||||
const targetHref = parsed.kind === "project"
|
||||
@@ -159,7 +175,7 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
|
||||
<Markdown remarkPlugins={remarkPlugins} components={components} urlTransform={(url) => url}>
|
||||
{children}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
||||
@@ -222,18 +222,6 @@ async function flush() {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForValue<T>(getValue: () => T | null | undefined, attempts = 10): Promise<T> {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const value = getValue();
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
|
||||
throw new Error("Timed out waiting for value");
|
||||
}
|
||||
|
||||
function renderDialog(container: HTMLDivElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -394,10 +382,15 @@ describe("NewIssueDialog", () => {
|
||||
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
|
||||
expect(dialogContent?.className).toContain("overflow-hidden");
|
||||
|
||||
const titleInput = container.querySelector('textarea[placeholder="Issue title"]');
|
||||
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");
|
||||
const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("overscroll-contain"),
|
||||
);
|
||||
expect(bodyScrollRegion?.className).toContain("flex-1");
|
||||
expect(bodyScrollRegion?.className).toContain("overflow-y-auto");
|
||||
expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true);
|
||||
expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
@@ -452,13 +445,13 @@ describe("NewIssueDialog", () => {
|
||||
|
||||
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
|
||||
|
||||
const modeSelect = await waitForValue(
|
||||
() => container.querySelector("select") as HTMLSelectElement | null,
|
||||
);
|
||||
const selects = Array.from(container.querySelectorAll("select"));
|
||||
const modeSelect = selects[0] as HTMLSelectElement | undefined;
|
||||
expect(modeSelect).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
modeSelect.value = "shared_workspace";
|
||||
modeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
modeSelect!.value = "shared_workspace";
|
||||
modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
|
||||
@@ -1056,9 +1056,10 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||
<textarea
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<textarea
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
||||
placeholder="Issue title"
|
||||
rows={1}
|
||||
@@ -1094,12 +1095,12 @@ export function NewIssueDialog() {
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
|
||||
<div className="px-4 pb-2">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
|
||||
<span className="w-6 shrink-0 text-center">For</span>
|
||||
<InlineEntitySelector
|
||||
ref={assigneeSelectorRef}
|
||||
@@ -1235,14 +1236,14 @@ export function NewIssueDialog() {
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviewer row */}
|
||||
{showReviewerRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><Eye className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
{/* Reviewer row */}
|
||||
{showReviewerRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><Eye className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
value={reviewerValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Reviewer"
|
||||
@@ -1278,15 +1279,15 @@ export function NewIssueDialog() {
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approver row */}
|
||||
{showApproverRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><ShieldCheck className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
{/* Approver row */}
|
||||
{showApproverRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><ShieldCheck className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
value={approverValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Approver"
|
||||
@@ -1322,13 +1323,13 @@ export function NewIssueDialog() {
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSubIssueMode ? (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
{isSubIssueMode ? (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="max-w-full rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ListTree className="h-3.5 w-3.5 shrink-0" />
|
||||
@@ -1341,11 +1342,11 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
||||
<div className="px-4 py-3 shrink-0 space-y-2">
|
||||
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium">Execution workspace</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
@@ -1392,11 +1393,11 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supportsAssigneeOverrides && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
{supportsAssigneeOverrides && (
|
||||
<div className="px-4 pb-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setAssigneeOptionsOpen((open) => !open)}
|
||||
@@ -1447,39 +1448,39 @@ export function NewIssueDialog() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
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}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md transition-colors",
|
||||
isFileDragOver && "bg-accent/20",
|
||||
)}
|
||||
className="border-t border-border/60 px-4 pb-2 pt-3"
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{stagedFiles.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md transition-colors",
|
||||
isFileDragOver && "bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{stagedFiles.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
|
||||
{stagedDocuments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Documents</div>
|
||||
@@ -1546,8 +1547,9 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Property chips bar */}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function Sidebar() {
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
</SidebarSection>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { useAgentOrder } from "../hooks/useAgentOrder";
|
||||
@@ -105,6 +106,7 @@ export function SidebarAgents() {
|
||||
<NavLink
|
||||
key={agent.id}
|
||||
to={activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent)}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -35,6 +36,7 @@ export function SidebarNavItem({
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
end={end}
|
||||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDialog } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
@@ -74,6 +75,7 @@ function SortableProjectItem({
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
|
||||
@@ -10,12 +10,15 @@ import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||
|
||||
function TestHarness({
|
||||
onNewIssue,
|
||||
onSearch,
|
||||
}: {
|
||||
onNewIssue: () => void;
|
||||
onSearch?: () => void;
|
||||
}) {
|
||||
useKeyboardShortcuts({
|
||||
enabled: true,
|
||||
onNewIssue,
|
||||
onSearch,
|
||||
});
|
||||
|
||||
return <div>keyboard shortcuts test</div>;
|
||||
@@ -55,4 +58,52 @@ describe("useKeyboardShortcuts", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses the current page search target on slash", () => {
|
||||
const root = createRoot(container);
|
||||
const onSearch = vi.fn();
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("data-page-search-target", "true");
|
||||
vi.spyOn(input, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
|
||||
document.body.appendChild(input);
|
||||
|
||||
act(() => {
|
||||
root.render(<TestHarness onNewIssue={vi.fn()} onSearch={onSearch} />);
|
||||
});
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "/",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("falls back to quick search when the page has no search target", () => {
|
||||
const root = createRoot(container);
|
||||
const onSearch = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(<TestHarness onNewIssue={vi.fn()} onSearch={onSearch} />);
|
||||
});
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "/",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
|
||||
expect(onSearch).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
import { isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import {
|
||||
focusPageSearchShortcutTarget,
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
|
||||
interface ShortcutHandlers {
|
||||
enabled?: boolean;
|
||||
onNewIssue?: () => void;
|
||||
onSearch?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
onTogglePanel?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
@@ -12,6 +17,7 @@ interface ShortcutHandlers {
|
||||
export function useKeyboardShortcuts({
|
||||
enabled = true,
|
||||
onNewIssue,
|
||||
onSearch,
|
||||
onToggleSidebar,
|
||||
onTogglePanel,
|
||||
onShowShortcuts,
|
||||
@@ -29,6 +35,19 @@ export function useKeyboardShortcuts({
|
||||
return;
|
||||
}
|
||||
|
||||
// / → Page search when available, otherwise quick search
|
||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (hasBlockingShortcutDialog()) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (!focusPageSearchShortcutTarget()) {
|
||||
onSearch?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ? → Show keyboard shortcuts cheatsheet
|
||||
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
@@ -57,5 +76,5 @@ export function useKeyboardShortcuts({
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel, onShowShortcuts]);
|
||||
}, [enabled, onNewIssue, onSearch, onToggleSidebar, onTogglePanel, onShowShortcuts]);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildInboxDismissedAtByKey,
|
||||
computeInboxBadgeData,
|
||||
filterInboxIssues,
|
||||
getArchivedInboxSearchIssues,
|
||||
getAvailableInboxIssueColumns,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
@@ -24,13 +25,16 @@ import {
|
||||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxFilterPreferences,
|
||||
loadInboxIssueColumns,
|
||||
loadLastInboxTab,
|
||||
matchesInboxIssueSearch,
|
||||
normalizeInboxIssueColumns,
|
||||
RECENT_ISSUES_LIMIT,
|
||||
resolveInboxNestingEnabled,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxFilterPreferences,
|
||||
saveInboxIssueColumns,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
@@ -547,6 +551,65 @@ describe("inbox helpers", () => {
|
||||
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("matches workspace names when inbox issue search includes workspace labels", () => {
|
||||
const issue = makeIssue("workspace", false);
|
||||
issue.projectId = "project-1";
|
||||
issue.projectWorkspaceId = "project-workspace-1";
|
||||
issue.executionWorkspaceId = "execution-workspace-1";
|
||||
|
||||
expect(matchesInboxIssueSearch(
|
||||
issue,
|
||||
"feature",
|
||||
{
|
||||
isolatedWorkspacesEnabled: true,
|
||||
executionWorkspaceById: new Map([
|
||||
["execution-workspace-1", { name: "Feature Branch", mode: "isolated_workspace" as const, projectWorkspaceId: "project-workspace-1" }],
|
||||
]),
|
||||
projectWorkspaceById: new Map([
|
||||
["project-workspace-1", { name: "Primary workspace" }],
|
||||
]),
|
||||
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "project-workspace-2"]]),
|
||||
},
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns archived search matches that are not already visible in the inbox", () => {
|
||||
const visibleIssue = makeIssue("visible", false);
|
||||
visibleIssue.title = "Alpha visible task";
|
||||
|
||||
const archivedMatch = makeIssue("archived-match", false);
|
||||
archivedMatch.title = "Alpha archived task";
|
||||
|
||||
const archivedMiss = makeIssue("archived-miss", false);
|
||||
archivedMiss.title = "Different task";
|
||||
|
||||
expect(
|
||||
getArchivedInboxSearchIssues({
|
||||
visibleIssues: [visibleIssue],
|
||||
searchableIssues: [visibleIssue, archivedMatch, archivedMiss],
|
||||
query: "alpha",
|
||||
}).map((issue) => issue.id),
|
||||
).toEqual(["archived-match"]);
|
||||
});
|
||||
|
||||
it("sorts archived search matches by most recent activity", () => {
|
||||
const older = makeIssue("older", false);
|
||||
older.title = "Alpha older";
|
||||
older.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
|
||||
|
||||
const newer = makeIssue("newer", false);
|
||||
newer.title = "Alpha newer";
|
||||
newer.lastActivityAt = new Date("2026-03-11T03:00:00.000Z");
|
||||
|
||||
expect(
|
||||
getArchivedInboxSearchIssues({
|
||||
visibleIssues: [],
|
||||
searchableIssues: [older, newer],
|
||||
query: "alpha",
|
||||
}).map((issue) => issue.id),
|
||||
).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("defaults the remembered inbox tab to mine and persists all", () => {
|
||||
localStorage.clear();
|
||||
expect(loadLastInboxTab()).toBe("mine");
|
||||
@@ -555,6 +618,92 @@ describe("inbox helpers", () => {
|
||||
expect(loadLastInboxTab()).toBe("all");
|
||||
});
|
||||
|
||||
it("persists inbox filters per company", () => {
|
||||
saveInboxFilterPreferences("company-1", {
|
||||
allCategoryFilter: "approvals",
|
||||
allApprovalFilter: "resolved",
|
||||
issueFilters: {
|
||||
statuses: ["todo"],
|
||||
priorities: ["high"],
|
||||
assignees: ["agent-1"],
|
||||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
saveInboxFilterPreferences("company-2", {
|
||||
allCategoryFilter: "failed_runs",
|
||||
allApprovalFilter: "actionable",
|
||||
issueFilters: {
|
||||
statuses: ["done"],
|
||||
priorities: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadInboxFilterPreferences("company-1")).toEqual({
|
||||
allCategoryFilter: "approvals",
|
||||
allApprovalFilter: "resolved",
|
||||
issueFilters: {
|
||||
statuses: ["todo"],
|
||||
priorities: ["high"],
|
||||
assignees: ["agent-1"],
|
||||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
expect(loadInboxFilterPreferences("company-2")).toEqual({
|
||||
allCategoryFilter: "failed_runs",
|
||||
allApprovalFilter: "actionable",
|
||||
issueFilters: {
|
||||
statuses: ["done"],
|
||||
priorities: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes invalid inbox filter storage back to safe defaults", () => {
|
||||
localStorage.setItem("paperclip:inbox:filters:company-1", JSON.stringify({
|
||||
allCategoryFilter: "bogus",
|
||||
allApprovalFilter: "bogus",
|
||||
issueFilters: {
|
||||
statuses: ["todo", 123],
|
||||
priorities: "high",
|
||||
assignees: ["agent-1"],
|
||||
labels: null,
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1", false],
|
||||
showRoutineExecutions: "yes",
|
||||
},
|
||||
}));
|
||||
|
||||
expect(loadInboxFilterPreferences("company-1")).toEqual({
|
||||
allCategoryFilter: "everything",
|
||||
allApprovalFilter: "all",
|
||||
issueFilters: {
|
||||
statuses: ["todo"],
|
||||
priorities: [],
|
||||
assignees: ["agent-1"],
|
||||
labels: [],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps nesting enabled on desktop when the saved preference is on", () => {
|
||||
expect(resolveInboxNestingEnabled(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
+197
-1
@@ -6,6 +6,10 @@ import type {
|
||||
Issue,
|
||||
JoinRequest,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
defaultIssueFilterState,
|
||||
type IssueFilterState,
|
||||
} from "./issue-filters";
|
||||
|
||||
export const RECENT_ISSUES_LIMIT = 100;
|
||||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||
@@ -16,12 +20,34 @@ export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
|
||||
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
|
||||
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxCategoryFilter =
|
||||
| "everything"
|
||||
| "issues_i_touched"
|
||||
| "join_requests"
|
||||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts";
|
||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
export type InboxWorkItemGroupBy = "none" | "type";
|
||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
||||
export const inboxIssueColumns = [
|
||||
"status",
|
||||
"id",
|
||||
"assignee",
|
||||
"project",
|
||||
"workspace",
|
||||
"parent",
|
||||
"labels",
|
||||
"updated",
|
||||
] as const;
|
||||
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
||||
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
||||
export interface InboxFilterPreferences {
|
||||
allCategoryFilter: InboxCategoryFilter;
|
||||
allApprovalFilter: InboxApprovalFilter;
|
||||
issueFilters: IssueFilterState;
|
||||
}
|
||||
export type InboxWorkItem =
|
||||
| {
|
||||
kind: "issue";
|
||||
@@ -59,6 +85,104 @@ export interface InboxWorkItemGroup {
|
||||
items: InboxWorkItem[];
|
||||
}
|
||||
|
||||
const defaultInboxFilterPreferences: InboxFilterPreferences = {
|
||||
allCategoryFilter: "everything",
|
||||
allApprovalFilter: "all",
|
||||
issueFilters: defaultIssueFilterState,
|
||||
};
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
function normalizeIssueFilterState(value: unknown): IssueFilterState {
|
||||
if (!value || typeof value !== "object") return { ...defaultIssueFilterState };
|
||||
const candidate = value as Partial<Record<keyof IssueFilterState, unknown>>;
|
||||
return {
|
||||
statuses: normalizeStringArray(candidate.statuses),
|
||||
priorities: normalizeStringArray(candidate.priorities),
|
||||
assignees: normalizeStringArray(candidate.assignees),
|
||||
labels: normalizeStringArray(candidate.labels),
|
||||
projects: normalizeStringArray(candidate.projects),
|
||||
workspaces: normalizeStringArray(candidate.workspaces),
|
||||
showRoutineExecutions: candidate.showRoutineExecutions === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter {
|
||||
return value === "issues_i_touched"
|
||||
|| value === "join_requests"
|
||||
|| value === "approvals"
|
||||
|| value === "failed_runs"
|
||||
|| value === "alerts"
|
||||
? value
|
||||
: "everything";
|
||||
}
|
||||
|
||||
function normalizeInboxApprovalFilter(value: unknown): InboxApprovalFilter {
|
||||
return value === "actionable" || value === "resolved" ? value : "all";
|
||||
}
|
||||
|
||||
function getInboxFilterPreferencesStorageKey(companyId: string | null | undefined): string | null {
|
||||
if (!companyId) return null;
|
||||
return `${INBOX_FILTER_PREFERENCES_KEY_PREFIX}:${companyId}`;
|
||||
}
|
||||
|
||||
export function loadInboxFilterPreferences(
|
||||
companyId: string | null | undefined,
|
||||
): InboxFilterPreferences {
|
||||
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
|
||||
if (!storageKey) {
|
||||
return {
|
||||
...defaultInboxFilterPreferences,
|
||||
issueFilters: { ...defaultIssueFilterState },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return {
|
||||
...defaultInboxFilterPreferences,
|
||||
issueFilters: { ...defaultIssueFilterState },
|
||||
};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
allCategoryFilter: normalizeInboxCategoryFilter(parsed.allCategoryFilter),
|
||||
allApprovalFilter: normalizeInboxApprovalFilter(parsed.allApprovalFilter),
|
||||
issueFilters: normalizeIssueFilterState(parsed.issueFilters),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...defaultInboxFilterPreferences,
|
||||
issueFilters: { ...defaultIssueFilterState },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInboxFilterPreferences(
|
||||
companyId: string | null | undefined,
|
||||
preferences: InboxFilterPreferences,
|
||||
) {
|
||||
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
allCategoryFilter: normalizeInboxCategoryFilter(preferences.allCategoryFilter),
|
||||
allApprovalFilter: normalizeInboxApprovalFilter(preferences.allApprovalFilter),
|
||||
issueFilters: normalizeIssueFilterState(preferences.issueFilters),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDismissedInboxAlerts(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||
@@ -174,6 +298,78 @@ export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolea
|
||||
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions));
|
||||
}
|
||||
|
||||
export function matchesInboxIssueSearch(
|
||||
issue: Pick<Issue, "title" | "identifier" | "description" | "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
|
||||
query: string,
|
||||
{
|
||||
isolatedWorkspacesEnabled = false,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
}: {
|
||||
isolatedWorkspacesEnabled?: boolean;
|
||||
executionWorkspaceById?: ReadonlyMap<string, {
|
||||
name: string;
|
||||
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}>;
|
||||
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
|
||||
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
||||
} = {},
|
||||
): boolean {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return true;
|
||||
if (issue.title.toLowerCase().includes(normalizedQuery)) return true;
|
||||
if (issue.identifier?.toLowerCase().includes(normalizedQuery)) return true;
|
||||
if (issue.description?.toLowerCase().includes(normalizedQuery)) return true;
|
||||
if (!isolatedWorkspacesEnabled) return false;
|
||||
|
||||
const workspaceName = resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
});
|
||||
return workspaceName?.toLowerCase().includes(normalizedQuery) ?? false;
|
||||
}
|
||||
|
||||
export function getArchivedInboxSearchIssues({
|
||||
visibleIssues,
|
||||
searchableIssues,
|
||||
query,
|
||||
isolatedWorkspacesEnabled = false,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
}: {
|
||||
visibleIssues: Issue[];
|
||||
searchableIssues: Issue[];
|
||||
query: string;
|
||||
isolatedWorkspacesEnabled?: boolean;
|
||||
executionWorkspaceById?: ReadonlyMap<string, {
|
||||
name: string;
|
||||
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}>;
|
||||
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
|
||||
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
||||
}): Issue[] {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) return [];
|
||||
|
||||
const visibleIssueIds = new Set(visibleIssues.map((issue) => issue.id));
|
||||
return searchableIssues
|
||||
.filter((issue) => !visibleIssueIds.has(issue.id))
|
||||
.filter((issue) =>
|
||||
matchesInboxIssueSearch(issue, normalizedQuery, {
|
||||
isolatedWorkspacesEnabled,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
}),
|
||||
)
|
||||
.sort(sortIssuesByMostRecentActivity);
|
||||
}
|
||||
|
||||
export function resolveIssueWorkspaceName(
|
||||
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ export type IssueFilterState = {
|
||||
assignees: string[];
|
||||
labels: string[];
|
||||
projects: string[];
|
||||
workspaces: string[];
|
||||
showRoutineExecutions: boolean;
|
||||
};
|
||||
|
||||
@@ -15,6 +16,7 @@ export const defaultIssueFilterState: IssueFilterState = {
|
||||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
};
|
||||
|
||||
@@ -71,6 +73,12 @@ export function applyIssueFilters(
|
||||
if (state.projects.length > 0) {
|
||||
result = result.filter((issue) => issue.projectId != null && state.projects.includes(issue.projectId));
|
||||
}
|
||||
if (state.workspaces.length > 0) {
|
||||
result = result.filter((issue) => {
|
||||
const workspaceId = issue.executionWorkspaceId ?? issue.projectWorkspaceId;
|
||||
return workspaceId != null && state.workspaces.includes(workspaceId);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -84,6 +92,7 @@ export function countActiveIssueFilters(
|
||||
if (state.assignees.length > 0) count += 1;
|
||||
if (state.labels.length > 0) count += 1;
|
||||
if (state.projects.length > 0) count += 1;
|
||||
if (state.workspaces.length > 0) count += 1;
|
||||
if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1;
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseIssuePathIdFromPath, parseIssueReferenceFromHref } from "./issue-reference";
|
||||
|
||||
describe("issue-reference", () => {
|
||||
it("extracts issue ids from company-scoped issue paths", () => {
|
||||
expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271");
|
||||
expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179");
|
||||
});
|
||||
|
||||
it("extracts issue ids from full issue URLs", () => {
|
||||
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
|
||||
});
|
||||
|
||||
it("normalizes bare identifiers and issue URLs into internal links", () => {
|
||||
expect(parseIssueReferenceFromHref("pap-1271")).toEqual({
|
||||
issuePathId: "PAP-1271",
|
||||
href: "/issues/PAP-1271",
|
||||
});
|
||||
expect(parseIssueReferenceFromHref("http://localhost:3100/PAP/issues/PAP-1179")).toEqual({
|
||||
issuePathId: "PAP-1179",
|
||||
href: "/issues/PAP-1179",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
type MarkdownNode = {
|
||||
type: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
children?: MarkdownNode[];
|
||||
};
|
||||
|
||||
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi;
|
||||
|
||||
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
|
||||
if (!pathOrUrl) return null;
|
||||
let pathname = pathOrUrl.trim();
|
||||
if (!pathname) return null;
|
||||
|
||||
if (/^https?:\/\//i.test(pathname)) {
|
||||
try {
|
||||
pathname = new URL(pathname).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const issueIndex = segments.findIndex((segment) => segment === "issues");
|
||||
if (issueIndex === -1 || issueIndex === segments.length - 1) return null;
|
||||
return decodeURIComponent(segments[issueIndex + 1] ?? "");
|
||||
}
|
||||
|
||||
export function parseIssueReferenceFromHref(href: string | null | undefined) {
|
||||
if (!href) return null;
|
||||
const pathId = parseIssuePathIdFromPath(href);
|
||||
if (pathId) {
|
||||
return {
|
||||
issuePathId: pathId,
|
||||
href: `/issues/${encodeURIComponent(pathId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = href.trim();
|
||||
if (!BARE_ISSUE_IDENTIFIER_RE.test(trimmed)) return null;
|
||||
const normalized = trimmed.toUpperCase();
|
||||
return {
|
||||
issuePathId: normalized,
|
||||
href: `/issues/${encodeURIComponent(normalized)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function splitTrailingPunctuation(token: string) {
|
||||
let core = token;
|
||||
let trailing = "";
|
||||
|
||||
while (core.length > 0) {
|
||||
const lastChar = core.at(-1);
|
||||
if (!lastChar || !/[),.;!?]/.test(lastChar)) break;
|
||||
if (lastChar === ")") {
|
||||
const openCount = (core.match(/\(/g) ?? []).length;
|
||||
const closeCount = (core.match(/\)/g) ?? []).length;
|
||||
if (closeCount <= openCount) break;
|
||||
}
|
||||
trailing = `${lastChar}${trailing}`;
|
||||
core = core.slice(0, -1);
|
||||
}
|
||||
|
||||
return { core, trailing };
|
||||
}
|
||||
|
||||
function createIssueLinkNode(value: string, href: string): MarkdownNode {
|
||||
return {
|
||||
type: "link",
|
||||
url: href,
|
||||
children: [{ type: "text", value }],
|
||||
};
|
||||
}
|
||||
|
||||
function linkifyIssueReferencesInText(value: string): MarkdownNode[] | null {
|
||||
const nodes: MarkdownNode[] = [];
|
||||
let cursor = 0;
|
||||
let matched = false;
|
||||
|
||||
for (const match of value.matchAll(ISSUE_REFERENCE_TOKEN_RE)) {
|
||||
const raw = match[0];
|
||||
if (!raw) continue;
|
||||
|
||||
const start = match.index ?? 0;
|
||||
const end = start + raw.length;
|
||||
const { core, trailing } = splitTrailingPunctuation(raw);
|
||||
const issueRef = parseIssueReferenceFromHref(core);
|
||||
if (!issueRef) continue;
|
||||
|
||||
matched = true;
|
||||
if (start > cursor) {
|
||||
nodes.push({ type: "text", value: value.slice(cursor, start) });
|
||||
}
|
||||
nodes.push(createIssueLinkNode(core, issueRef.href));
|
||||
if (trailing) {
|
||||
nodes.push({ type: "text", value: trailing });
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
if (!matched) return null;
|
||||
if (cursor < value.length) {
|
||||
nodes.push({ type: "text", value: value.slice(cursor) });
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function rewriteMarkdownTree(node: MarkdownNode) {
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) return;
|
||||
if (node.type === "link" || node.type === "linkReference" || node.type === "inlineCode" || node.type === "code" || node.type === "definition" || node.type === "html") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChildren: MarkdownNode[] = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type === "text" && typeof child.value === "string") {
|
||||
const linked = linkifyIssueReferencesInText(child.value);
|
||||
if (linked) {
|
||||
nextChildren.push(...linked);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
rewriteMarkdownTree(child);
|
||||
nextChildren.push(child);
|
||||
}
|
||||
node.children = nextChildren;
|
||||
}
|
||||
|
||||
export function remarkLinkIssueReferences() {
|
||||
return (tree: MarkdownNode) => {
|
||||
rewriteMarkdownTree(tree);
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
findPageSearchShortcutTarget,
|
||||
focusPageSearchShortcutTarget,
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
resolveIssueDetailGoKeyAction,
|
||||
resolveInboxQuickArchiveKeyAction,
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "./keyboardShortcuts";
|
||||
|
||||
describe("keyboardShortcuts helpers", () => {
|
||||
@@ -40,6 +44,72 @@ describe("keyboardShortcuts helpers", () => {
|
||||
|
||||
expect(hasBlockingShortcutDialog(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the visible page search shortcut target", () => {
|
||||
const root = document.createElement("div");
|
||||
const hidden = document.createElement("input");
|
||||
hidden.setAttribute("data-page-search-target", "true");
|
||||
vi.spyOn(hidden, "getClientRects").mockReturnValue([] as unknown as DOMRectList);
|
||||
|
||||
const visible = document.createElement("input");
|
||||
visible.setAttribute("data-page-search-target", "true");
|
||||
vi.spyOn(visible, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
|
||||
|
||||
root.append(hidden, visible);
|
||||
document.body.appendChild(root);
|
||||
|
||||
expect(findPageSearchShortcutTarget(root)).toBe(visible);
|
||||
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it("focuses and selects the page search shortcut target", () => {
|
||||
const root = document.createElement("div");
|
||||
const input = document.createElement("input");
|
||||
input.value = "existing query";
|
||||
input.setAttribute("data-page-search-target", "true");
|
||||
vi.spyOn(input, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
|
||||
root.appendChild(input);
|
||||
document.body.appendChild(root);
|
||||
|
||||
expect(focusPageSearchShortcutTarget(root)).toBe(true);
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe(input.value.length);
|
||||
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it("blurs page search on a plain Enter press", () => {
|
||||
expect(shouldBlurPageSearchOnEnter({
|
||||
key: "Enter",
|
||||
isComposing: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps focus while composing with an IME", () => {
|
||||
expect(shouldBlurPageSearchOnEnter({
|
||||
key: "Enter",
|
||||
isComposing: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("blurs page search on Escape when the field is already empty", () => {
|
||||
expect(shouldBlurPageSearchOnEscape({
|
||||
key: "Escape",
|
||||
isComposing: false,
|
||||
currentValue: "",
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps focus on the first Escape while the field still has text", () => {
|
||||
expect(shouldBlurPageSearchOnEscape({
|
||||
key: "Escape",
|
||||
isComposing: false,
|
||||
currentValue: "query",
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("archives only the first clean y press", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
|
||||
"[role='combobox']",
|
||||
].join(", ");
|
||||
|
||||
const PAGE_SEARCH_SHORTCUT_SELECTOR = "[data-page-search-target='true']";
|
||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||
|
||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||
@@ -23,6 +24,56 @@ export function hasBlockingShortcutDialog(root: ParentNode = document): boolean
|
||||
return !!root.querySelector("[role='dialog'][aria-modal='true']");
|
||||
}
|
||||
|
||||
function isVisibleShortcutTarget(element: HTMLElement): boolean {
|
||||
if (!element.isConnected) return false;
|
||||
if ("disabled" in element && typeof element.disabled === "boolean" && element.disabled) return false;
|
||||
if (element.closest("[hidden], [aria-hidden='true'], [inert]")) return false;
|
||||
if (element.closest("[role='dialog'][aria-modal='true']")) return false;
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||
|
||||
return element.getClientRects().length > 0 || element === document.activeElement;
|
||||
}
|
||||
|
||||
export function findPageSearchShortcutTarget(root: ParentNode = document): HTMLElement | null {
|
||||
const candidates = Array.from(root.querySelectorAll<HTMLElement>(PAGE_SEARCH_SHORTCUT_SELECTOR));
|
||||
return candidates.find((candidate) => isVisibleShortcutTarget(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export function focusPageSearchShortcutTarget(root: ParentNode = document): boolean {
|
||||
const target = findPageSearchShortcutTarget(root);
|
||||
if (!target) return false;
|
||||
|
||||
target.focus();
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||
target.select();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldBlurPageSearchOnEnter({
|
||||
key,
|
||||
isComposing,
|
||||
}: {
|
||||
key: string;
|
||||
isComposing: boolean;
|
||||
}): boolean {
|
||||
return key === "Enter" && !isComposing;
|
||||
}
|
||||
|
||||
export function shouldBlurPageSearchOnEscape({
|
||||
key,
|
||||
isComposing,
|
||||
currentValue,
|
||||
}: {
|
||||
key: string;
|
||||
isComposing: boolean;
|
||||
currentValue: string;
|
||||
}): boolean {
|
||||
return key === "Escape" && !isComposing && currentValue.length === 0;
|
||||
}
|
||||
|
||||
export function isModifierOnlyKey(key: string): boolean {
|
||||
return MODIFIER_ONLY_KEYS.has(key);
|
||||
}
|
||||
|
||||
@@ -312,6 +312,22 @@ describe("optimistic issue comments", () => {
|
||||
projectWorkspaceId: "workspace-1",
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
ancestors: [
|
||||
{
|
||||
id: "issue-9",
|
||||
identifier: "PAP-9",
|
||||
title: "Old parent",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
project: null,
|
||||
goal: null,
|
||||
},
|
||||
],
|
||||
title: "Fix property pane",
|
||||
description: null,
|
||||
status: "todo",
|
||||
@@ -449,6 +465,7 @@ describe("optimistic issue comments", () => {
|
||||
assigneeUserId: "board-2",
|
||||
labelIds: ["label-2"],
|
||||
blockedByIssueIds: ["issue-3"],
|
||||
parentId: "issue-4",
|
||||
projectId: "project-2",
|
||||
executionWorkspaceId: "exec-2",
|
||||
},
|
||||
@@ -460,6 +477,8 @@ describe("optimistic issue comments", () => {
|
||||
expect(next?.labelIds).toEqual(["label-2"]);
|
||||
expect(next?.labels?.map((label) => label.id)).toEqual(["label-2"]);
|
||||
expect(next?.blockedBy?.map((relation) => relation.id)).toEqual(["issue-3"]);
|
||||
expect(next?.parentId).toBe("issue-4");
|
||||
expect(next?.ancestors).toBeUndefined();
|
||||
expect(next?.projectId).toBe("project-2");
|
||||
expect(next?.project).toBeNull();
|
||||
expect(next?.executionWorkspaceId).toBe("exec-2");
|
||||
|
||||
@@ -169,6 +169,7 @@ export function applyOptimisticIssueFieldUpdate(
|
||||
assign("assigneeAgentId");
|
||||
assign("assigneeUserId");
|
||||
assign("projectId");
|
||||
assign("parentId");
|
||||
assign("projectWorkspaceId");
|
||||
assign("executionWorkspaceId");
|
||||
assign("executionWorkspacePreference");
|
||||
@@ -194,6 +195,10 @@ export function applyOptimisticIssueFieldUpdate(
|
||||
nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null;
|
||||
}
|
||||
|
||||
if (hasOwn("parentId")) {
|
||||
nextIssue.ancestors = undefined;
|
||||
}
|
||||
|
||||
if (hasOwn("executionWorkspaceId")) {
|
||||
nextIssue.currentExecutionWorkspace =
|
||||
issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId
|
||||
|
||||
@@ -22,7 +22,6 @@ function createLiveRun(overrides: Partial<LiveRunForIssue> = {}): LiveRunForIssu
|
||||
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
|
||||
return {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
@@ -31,30 +30,7 @@ function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunF
|
||||
status: "running",
|
||||
startedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
wakeupRequestId: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
usageJson: { inputTokens: 1 },
|
||||
resultJson: { summary: "partial" },
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
contextSnapshot: null,
|
||||
createdAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-08T21:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
type MarkdownNode = {
|
||||
type?: unknown;
|
||||
value?: unknown;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
type MarkdownTextNode = {
|
||||
type: "text";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type MarkdownBreakNode = {
|
||||
type: "break";
|
||||
};
|
||||
|
||||
type MarkdownParentNode = {
|
||||
children: MarkdownTreeNode[];
|
||||
};
|
||||
|
||||
type MarkdownTreeNode = MarkdownTextNode | MarkdownBreakNode | (MarkdownNode & { children?: MarkdownTreeNode[] });
|
||||
|
||||
function isParentNode(value: unknown): value is MarkdownParentNode {
|
||||
return typeof value === "object" && value !== null && Array.isArray((value as MarkdownNode).children);
|
||||
}
|
||||
|
||||
function buildSoftBreakReplacement(value: string): Array<MarkdownTextNode | MarkdownBreakNode> {
|
||||
const parts = value.split("\n");
|
||||
const replacement: Array<MarkdownTextNode | MarkdownBreakNode> = [];
|
||||
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
const part = parts[index];
|
||||
if (part.length > 0) {
|
||||
replacement.push({ type: "text", value: part });
|
||||
}
|
||||
if (index < parts.length - 1) {
|
||||
replacement.push({ type: "break" });
|
||||
}
|
||||
}
|
||||
|
||||
return replacement.length > 0 ? replacement : [{ type: "text", value: "" }];
|
||||
}
|
||||
|
||||
function transformNode(node: MarkdownTreeNode) {
|
||||
if (!isParentNode(node)) return;
|
||||
|
||||
for (let index = 0; index < node.children.length; index += 1) {
|
||||
const child = node.children[index];
|
||||
if (child?.type === "text" && typeof child.value === "string" && child.value.includes("\n")) {
|
||||
const replacement = buildSoftBreakReplacement(child.value);
|
||||
node.children.splice(index, 1, ...replacement);
|
||||
index += replacement.length - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
transformNode(child);
|
||||
}
|
||||
}
|
||||
|
||||
export function remarkSoftBreaks() {
|
||||
return (tree: MarkdownTreeNode) => {
|
||||
transformNode(tree);
|
||||
};
|
||||
}
|
||||
+18
-3
@@ -2,11 +2,13 @@ import * as React from "react";
|
||||
import * as RouterDom from "react-router-dom";
|
||||
import type { NavigateOptions, To } from "react-router-dom";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
|
||||
import {
|
||||
applyCompanyPrefix,
|
||||
extractCompanyPrefixFromPath,
|
||||
normalizeCompanyPrefix,
|
||||
} from "@/lib/company-routes";
|
||||
import { parseIssuePathIdFromPath } from "@/lib/issue-reference";
|
||||
|
||||
function resolveTo(to: To, companyPrefix: string | null): To {
|
||||
if (typeof to === "string") {
|
||||
@@ -40,10 +42,23 @@ function useActiveCompanyPrefix(): string | null {
|
||||
|
||||
export * from "react-router-dom";
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.Link>>(
|
||||
function CompanyLink({ to, ...props }, ref) {
|
||||
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
|
||||
disableIssueQuicklook?: boolean;
|
||||
};
|
||||
|
||||
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
|
||||
function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) {
|
||||
const companyPrefix = useActiveCompanyPrefix();
|
||||
return <RouterDom.Link ref={ref} to={resolveTo(to, companyPrefix)} {...props} />;
|
||||
const resolvedTo = resolveTo(to, companyPrefix);
|
||||
const issuePathId = disableIssueQuicklook
|
||||
? null
|
||||
: parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
|
||||
|
||||
if (issuePathId) {
|
||||
return <IssueLinkQuicklook ref={ref} to={resolvedTo} issuePathId={issuePathId} {...props} />;
|
||||
}
|
||||
|
||||
return <RouterDom.Link ref={ref} to={resolvedTo} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ export function ApprovalDetail() {
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -532,10 +532,10 @@ function ExportPreviewPane({
|
||||
{parsed ? (
|
||||
<>
|
||||
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false}>{parsed.body}</MarkdownBody>}
|
||||
</>
|
||||
) : isMarkdown ? (
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false}>{textContent ?? ""}</MarkdownBody>
|
||||
) : imageSrc ? (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
||||
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
|
||||
@@ -983,6 +983,7 @@ export function CompanyExport() {
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,10 +241,10 @@ function ImportPreviewPane({
|
||||
{parsed ? (
|
||||
<>
|
||||
<FrontmatterCard data={parsed.data} />
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false}>{parsed.body}</MarkdownBody>}
|
||||
</>
|
||||
) : isMarkdown ? (
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false}>{textContent ?? ""}</MarkdownBody>
|
||||
) : imageSrc ? (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
||||
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
|
||||
|
||||
@@ -742,7 +742,7 @@ function SkillPane({
|
||||
/>
|
||||
)
|
||||
) : file.markdown && viewMode === "preview" ? (
|
||||
<MarkdownBody>{body}</MarkdownBody>
|
||||
<MarkdownBody softBreaks={false}>{body}</MarkdownBody>
|
||||
) : (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 font-mono text-sm text-foreground">
|
||||
<code>{file.content}</code>
|
||||
|
||||
+188
-67
@@ -21,7 +21,6 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
defaultIssueFilterState,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import {
|
||||
@@ -31,7 +30,12 @@ import {
|
||||
rememberIssueDetailLocationState,
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import {
|
||||
@@ -91,13 +95,16 @@ import {
|
||||
buildInboxNesting,
|
||||
getAvailableInboxIssueColumns,
|
||||
getApprovalsForTab,
|
||||
getArchivedInboxSearchIssues,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getLatestFailedRunsByAgent,
|
||||
matchesInboxIssueSearch,
|
||||
getRecentTouchedIssues,
|
||||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxFilterPreferences,
|
||||
loadInboxIssueColumns,
|
||||
loadInboxNesting,
|
||||
loadInboxWorkItemGroupBy,
|
||||
@@ -105,10 +112,13 @@ import {
|
||||
resolveInboxNestingEnabled,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxFilterPreferences,
|
||||
saveInboxIssueColumns,
|
||||
saveInboxNesting,
|
||||
saveInboxWorkItemGroupBy,
|
||||
type InboxApprovalFilter,
|
||||
type InboxCategoryFilter,
|
||||
type InboxFilterPreferences,
|
||||
type InboxIssueColumn,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
@@ -119,14 +129,6 @@ import {
|
||||
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
|
||||
|
||||
type InboxCategoryFilter =
|
||||
| "everything"
|
||||
| "issues_i_touched"
|
||||
| "join_requests"
|
||||
| "approvals"
|
||||
| "failed_runs"
|
||||
| "alerts";
|
||||
type SectionKey =
|
||||
| "work_items"
|
||||
| "alerts";
|
||||
@@ -141,8 +143,32 @@ type InboxGroupedSection = {
|
||||
label: string | null;
|
||||
displayItems: InboxWorkItem[];
|
||||
childrenByIssueId: Map<string, Issue[]>;
|
||||
isArchivedSearch: boolean;
|
||||
};
|
||||
|
||||
function buildGroupedInboxSections(
|
||||
items: InboxWorkItem[],
|
||||
groupBy: InboxWorkItemGroupBy,
|
||||
nestingEnabled: boolean,
|
||||
options?: { keyPrefix?: string; isArchivedSearch?: boolean },
|
||||
): InboxGroupedSection[] {
|
||||
const keyPrefix = options?.keyPrefix ?? "";
|
||||
const isArchivedSearch = options?.isArchivedSearch ?? false;
|
||||
return groupInboxWorkItems(items, groupBy).map((group) => {
|
||||
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
|
||||
? buildInboxNesting(group.items)
|
||||
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${group.key}`,
|
||||
label: group.label,
|
||||
displayItems: nestedGroup.displayItems,
|
||||
childrenByIssueId: nestedGroup.childrenByIssueId,
|
||||
isArchivedSearch,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
@@ -616,14 +642,15 @@ export function Inbox() {
|
||||
retry: false,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const [issueFilters, setIssueFilters] = useState<IssueFilterState>(defaultIssueFilterState);
|
||||
const [filterPreferences, setFilterPreferences] = useState<InboxFilterPreferences>(
|
||||
() => loadInboxFilterPreferences(selectedCompanyId),
|
||||
);
|
||||
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
|
||||
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
|
||||
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||
const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences;
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||
const tab: InboxTab =
|
||||
@@ -681,6 +708,14 @@ export function Inbox() {
|
||||
setSearchQuery("");
|
||||
}, [tab]);
|
||||
|
||||
const previousSelectedCompanyIdRef = useRef<string | null>(selectedCompanyId);
|
||||
useEffect(() => {
|
||||
if (previousSelectedCompanyIdRef.current !== selectedCompanyId) {
|
||||
previousSelectedCompanyIdRef.current = selectedCompanyId;
|
||||
setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
|
||||
}
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const {
|
||||
data: approvals,
|
||||
isLoading: isApprovalsLoading,
|
||||
@@ -909,19 +944,12 @@ export function Inbox() {
|
||||
if (!q) return workItemsToRender;
|
||||
return workItemsToRender.filter((item) => {
|
||||
if (item.kind === "issue") {
|
||||
const issue = item.issue;
|
||||
if (issue.title.toLowerCase().includes(q)) return true;
|
||||
if (issue.identifier?.toLowerCase().includes(q)) return true;
|
||||
if (issue.description?.toLowerCase().includes(q)) return true;
|
||||
if (isolatedWorkspacesEnabled) {
|
||||
const workspaceName = resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
});
|
||||
if (workspaceName?.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
return false;
|
||||
return matchesInboxIssueSearch(item.issue, q, {
|
||||
isolatedWorkspacesEnabled,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
});
|
||||
}
|
||||
if (item.kind === "approval") {
|
||||
const a = item.approval;
|
||||
@@ -963,6 +991,35 @@ export function Inbox() {
|
||||
projectWorkspaceById,
|
||||
]);
|
||||
|
||||
const archivedSearchIssues = useMemo(
|
||||
() =>
|
||||
tab === "mine"
|
||||
? getArchivedInboxSearchIssues({
|
||||
visibleIssues: visibleMineIssues,
|
||||
searchableIssues: visibleTouchedIssues,
|
||||
query: searchQuery,
|
||||
isolatedWorkspacesEnabled,
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
})
|
||||
: [],
|
||||
[
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
executionWorkspaceById,
|
||||
isolatedWorkspacesEnabled,
|
||||
projectWorkspaceById,
|
||||
searchQuery,
|
||||
tab,
|
||||
visibleMineIssues,
|
||||
visibleTouchedIssues,
|
||||
],
|
||||
);
|
||||
const archivedSearchIssueIds = useMemo(
|
||||
() => new Set(archivedSearchIssues.map((issue) => issue.id)),
|
||||
[archivedSearchIssues],
|
||||
);
|
||||
|
||||
// --- Parent-child nesting for inbox issues ---
|
||||
const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting());
|
||||
const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile);
|
||||
@@ -974,33 +1031,15 @@ export function Inbox() {
|
||||
});
|
||||
}, []);
|
||||
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
|
||||
const groupedSections = useMemo<InboxGroupedSection[]>(() => {
|
||||
return groupInboxWorkItems(filteredWorkItems, groupBy).map((group) => {
|
||||
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
|
||||
? buildInboxNesting(group.items)
|
||||
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
displayItems: nestedGroup.displayItems,
|
||||
childrenByIssueId: nestedGroup.childrenByIssueId,
|
||||
};
|
||||
});
|
||||
}, [filteredWorkItems, groupBy, nestingEnabled]);
|
||||
const nestedWorkItems = useMemo(
|
||||
() => groupedSections.flatMap((group) => group.displayItems),
|
||||
[groupedSections],
|
||||
);
|
||||
const childrenByIssueId = useMemo(() => {
|
||||
const merged = new Map<string, Issue[]>();
|
||||
for (const group of groupedSections) {
|
||||
for (const [issueId, children] of group.childrenByIssueId) {
|
||||
merged.set(issueId, children);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [groupedSections]);
|
||||
const groupedSections = useMemo<InboxGroupedSection[]>(() => [
|
||||
...buildGroupedInboxSections(filteredWorkItems, groupBy, nestingEnabled),
|
||||
...buildGroupedInboxSections(
|
||||
getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
|
||||
groupBy,
|
||||
nestingEnabled,
|
||||
{ keyPrefix: "archived-search:", isArchivedSearch: true },
|
||||
),
|
||||
], [archivedSearchIssues, filteredWorkItems, groupBy, nestingEnabled]);
|
||||
const totalVisibleWorkItems = useMemo(
|
||||
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
|
||||
[groupedSections],
|
||||
@@ -1052,9 +1091,28 @@ export function Inbox() {
|
||||
}
|
||||
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
||||
}, [setIssueColumns, visibleIssueColumns]);
|
||||
const updateFilterPreferences = useCallback(
|
||||
(updater: (previous: InboxFilterPreferences) => InboxFilterPreferences) => {
|
||||
setFilterPreferences((previous) => {
|
||||
const next = updater(previous);
|
||||
saveInboxFilterPreferences(selectedCompanyId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[selectedCompanyId],
|
||||
);
|
||||
const updateIssueFilters = useCallback((patch: Partial<IssueFilterState>) => {
|
||||
setIssueFilters((previous) => ({ ...previous, ...patch }));
|
||||
}, []);
|
||||
updateFilterPreferences((previous) => ({
|
||||
...previous,
|
||||
issueFilters: { ...previous.issueFilters, ...patch },
|
||||
}));
|
||||
}, [updateFilterPreferences]);
|
||||
const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => {
|
||||
updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value }));
|
||||
}, [updateFilterPreferences]);
|
||||
const updateAllApprovalFilter = useCallback((value: InboxApprovalFilter) => {
|
||||
updateFilterPreferences((previous) => ({ ...previous, allApprovalFilter: value }));
|
||||
}, [updateFilterPreferences]);
|
||||
const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => {
|
||||
setGroupBy(nextGroupBy);
|
||||
saveInboxWorkItemGroupBy(nextGroupBy);
|
||||
@@ -1325,6 +1383,7 @@ export function Inbox() {
|
||||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivedSearchIssueIds,
|
||||
archivingIssueIds,
|
||||
archivingNonIssueIds,
|
||||
fadingOutIssues,
|
||||
@@ -1335,6 +1394,7 @@ export function Inbox() {
|
||||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivedSearchIssueIds,
|
||||
archivingIssueIds,
|
||||
archivingNonIssueIds,
|
||||
fadingOutIssues,
|
||||
@@ -1415,10 +1475,12 @@ export function Inbox() {
|
||||
e.preventDefault();
|
||||
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||
if (issue) {
|
||||
if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
|
||||
if (!st.archivedSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
|
||||
} else if (item) {
|
||||
if (item.kind === "issue") {
|
||||
if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id);
|
||||
if (!st.archivedSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) {
|
||||
act.archiveIssue(item.issue.id);
|
||||
}
|
||||
} else {
|
||||
const key = getWorkItemKey(item);
|
||||
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
|
||||
@@ -1553,10 +1615,28 @@ export function Inbox() {
|
||||
placeholder="Search inbox…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (shouldBlurPageSearchOnEnter({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBlurPageSearchOnEscape({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
currentValue: e.currentTarget.value,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="h-8 w-full pl-8 text-xs"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
@@ -1582,7 +1662,25 @@ export function Inbox() {
|
||||
placeholder="Search inbox…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (shouldBlurPageSearchOnEnter({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBlurPageSearchOnEscape({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
currentValue: e.currentTarget.value,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="h-8 w-[220px] pl-8 text-xs"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1604,17 +1702,20 @@ export function Inbox() {
|
||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace" && w.status === "active").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-8 shrink-0 text-xs", groupBy !== "none" && "bg-accent")}
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
|
||||
title="Group"
|
||||
>
|
||||
<Layers className="mr-1.5 h-3.5 w-3.5" />
|
||||
Group
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-2">
|
||||
@@ -1645,6 +1746,7 @@ export function Inbox() {
|
||||
onToggleColumn={toggleIssueColumn}
|
||||
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||
title="Choose which inbox columns stay visible"
|
||||
iconOnly
|
||||
/>
|
||||
{canMarkAllRead && (
|
||||
<>
|
||||
@@ -1691,7 +1793,7 @@ export function Inbox() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
onValueChange={(value) => updateAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
@@ -1709,7 +1811,7 @@ export function Inbox() {
|
||||
{showApprovalsCategory && (
|
||||
<Select
|
||||
value={allApprovalFilter}
|
||||
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
onValueChange={(value) => updateAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Approval status" />
|
||||
@@ -1783,6 +1885,7 @@ export function Inbox() {
|
||||
isExpanded = false,
|
||||
childCount = 0,
|
||||
collapseParentId = null,
|
||||
allowArchive = canArchiveFromTab,
|
||||
}: {
|
||||
issue: Issue;
|
||||
depth: number;
|
||||
@@ -1791,6 +1894,7 @@ export function Inbox() {
|
||||
isExpanded?: boolean;
|
||||
childCount?: number;
|
||||
collapseParentId?: string | null;
|
||||
allowArchive?: boolean;
|
||||
}) => {
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
@@ -1857,7 +1961,7 @@ export function Inbox() {
|
||||
}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined}
|
||||
onArchive={allowArchive ? () => archiveIssueMutation.mutate(issue.id) : undefined}
|
||||
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
||||
desktopTrailing={
|
||||
visibleTrailingIssueColumns.length > 0 ? (
|
||||
@@ -1885,6 +1989,20 @@ export function Inbox() {
|
||||
let previousTimestamp = Number.POSITIVE_INFINITY;
|
||||
return groupedSections.flatMap((group, groupIndex) => {
|
||||
const elements: ReactNode[] = [];
|
||||
if (group.isArchivedSearch && (groupIndex === 0 || !groupedSections[groupIndex - 1]?.isArchivedSearch)) {
|
||||
elements.push(
|
||||
<div
|
||||
key="archived-search-divider"
|
||||
className="flex items-center gap-3 border-y border-border/70 bg-muted/30 px-4 py-2"
|
||||
>
|
||||
<div className="h-px flex-1 bg-border/80" />
|
||||
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/80" />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
if (group.label) {
|
||||
elements.push(
|
||||
<div
|
||||
@@ -2044,6 +2162,7 @@ export function Inbox() {
|
||||
const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
|
||||
const hasChildren = childIssues.length > 0;
|
||||
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
|
||||
const canArchiveIssue = canArchiveFromTab && !group.isArchivedSearch;
|
||||
const parentRow = renderInboxIssue({
|
||||
issue,
|
||||
depth: 0,
|
||||
@@ -2052,9 +2171,10 @@ export function Inbox() {
|
||||
isExpanded,
|
||||
childCount: childIssues.length,
|
||||
collapseParentId: issue.id,
|
||||
allowArchive: canArchiveIssue,
|
||||
});
|
||||
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveIssue ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
@@ -2073,6 +2193,7 @@ export function Inbox() {
|
||||
issue: child,
|
||||
depth: 1,
|
||||
selected: isChildSelected,
|
||||
allowArchive: canArchiveIssue,
|
||||
});
|
||||
const isChildArchiving = archivingIssueIds.has(child.id);
|
||||
elements.push(
|
||||
@@ -2082,7 +2203,7 @@ export function Inbox() {
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(childNavIdx)}
|
||||
>
|
||||
{canArchiveFromTab ? (
|
||||
{canArchiveIssue ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${child.id}`}
|
||||
selected={isChildSelected}
|
||||
|
||||
Reference in new issue
Block a user