Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: (55 commits) fix(issue-documents): address greptile review Update packages/shared/src/validators/issue.ts feat(ui): add issue document copy and download actions fix(ui): unify new issue upload action feat(ui): stage issue files before create feat(ui): handle issue document edit conflicts fix(ui): refresh issue documents from live events feat(ui): deep link issue documents fix(ui): streamline issue document chrome fix(ui): collapse empty document and attachment states fix(ui): simplify document card body layout fix(issues): address document review comments feat(issues): add issue documents and inline editing docs: add agent evals framework plan fix(cli): quote env values with special characters Fix worktree seed source selection fix: address greptile follow-up docs: add paperclip skill tightening plan fix: isolate codex home in worktrees Add worktree UI branding ... # Conflicts: # packages/db/src/migrations/meta/0028_snapshot.json # packages/db/src/migrations/meta/_journal.json # packages/shared/src/index.ts # server/src/routes/issues.ts # ui/src/api/issues.ts # ui/src/components/NewIssueDialog.tsx # ui/src/pages/IssueDetail.tsx
This commit is contained in:
@@ -41,6 +41,8 @@ export const api = {
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
postForm: <T>(path: string, body: FormData) =>
|
||||
request<T>(path, { method: "POST", body }),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import type {
|
||||
Approval,
|
||||
DocumentRevision,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
IssueWorkProduct,
|
||||
UpsertIssueDocument,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const issuesApi = {
|
||||
@@ -53,6 +63,14 @@ export const issuesApi = {
|
||||
...(interrupt === undefined ? {} : { interrupt }),
|
||||
},
|
||||
),
|
||||
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
||||
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
||||
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
||||
listDocumentRevisions: (id: string, key: string) =>
|
||||
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
|
||||
deleteDocument: (id: string, key: string) =>
|
||||
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
|
||||
uploadAttachment: (
|
||||
companyId: string,
|
||||
|
||||
@@ -12,6 +12,9 @@ const ACTION_VERBS: Record<string, string> = {
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.document_created": "created document for",
|
||||
"issue.document_updated": "updated document on",
|
||||
"issue.document_deleted": "deleted document from",
|
||||
"issue.commented": "commented on",
|
||||
"issue.deleted": "deleted",
|
||||
"agent.created": "created",
|
||||
|
||||
@@ -444,23 +444,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
/>
|
||||
</Field>
|
||||
{isLocal && (
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"promptTemplate",
|
||||
String(config.promptTemplate ?? ""),
|
||||
)}
|
||||
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${props.agent.id}/prompt-template`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"promptTemplate",
|
||||
String(config.promptTemplate ?? ""),
|
||||
)}
|
||||
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${props.agent.id}/prompt-template`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,19 +581,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
|
||||
{isLocal && isCreate && (
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={val!.promptTemplate}
|
||||
onChange={(v) => set!({ promptTemplate: v })}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = "agents/drafts/prompt-template";
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={val!.promptTemplate}
|
||||
onChange={(v) => set!({ promptTemplate: v })}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = "agents/drafts/prompt-template";
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Adapter-specific fields */}
|
||||
@@ -704,6 +714,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
|
||||
Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it.
|
||||
</div>
|
||||
{adapterType === "claude_local" && (
|
||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
|
||||
interface InlineEditorProps {
|
||||
value: string;
|
||||
onSave: (value: string) => void;
|
||||
onSave: (value: string) => void | Promise<unknown>;
|
||||
as?: "h1" | "h2" | "p" | "span";
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
@@ -17,6 +16,8 @@ interface InlineEditorProps {
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
const pad = "px-1 -mx-1";
|
||||
const markdownPad = "px-1";
|
||||
const AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
|
||||
export function InlineEditor({
|
||||
value,
|
||||
@@ -29,12 +30,30 @@ export function InlineEditor({
|
||||
mentions,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [multilineFocused, setMultilineFocused] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
|
||||
useEffect(() => {
|
||||
if (multiline && multilineFocused) return;
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
}, [value, multiline, multilineFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return;
|
||||
@@ -52,58 +71,140 @@ export function InlineEditor({
|
||||
}
|
||||
}, [editing, autoSize]);
|
||||
|
||||
function commit() {
|
||||
const trimmed = draft.trim();
|
||||
useEffect(() => {
|
||||
if (!editing || !multiline) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
markdownRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [editing, multiline]);
|
||||
|
||||
const commit = useCallback(async (nextValue = draft) => {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed && trimmed !== value) {
|
||||
onSave(trimmed);
|
||||
await Promise.resolve(onSave(trimmed));
|
||||
} else {
|
||||
setDraft(value);
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
if (!multiline) {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [draft, multiline, onSave, value]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && !multiline) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
reset();
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
if (multiline) {
|
||||
setMultilineFocused(false);
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
if (multiline) {
|
||||
return (
|
||||
<div className={cn("space-y-2", pad)}>
|
||||
<MarkdownEditor
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={placeholder}
|
||||
contentClassName={className}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={commit}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={commit}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!multiline) return;
|
||||
if (!multilineFocused) return;
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
if (autosaveState !== "saved") {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDirty();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void runSave(() => commit(trimmed));
|
||||
}, AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, reset, runSave, value]);
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
markdownPad,
|
||||
"rounded transition-colors",
|
||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||
)}
|
||||
onFocusCapture={() => setMultilineFocused(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
setMultilineFocused(false);
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={markdownRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={placeholder}
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={() => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-4 items-center justify-end pr-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] transition-opacity duration-150",
|
||||
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
autosaveState === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: "Idle"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
|
||||
return (
|
||||
<textarea
|
||||
@@ -114,7 +215,9 @@ export function InlineEditor({
|
||||
setDraft(e.target.value);
|
||||
autoSize(e.target);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onBlur={() => {
|
||||
void commit();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
|
||||
@@ -135,15 +238,11 @@ export function InlineEditor({
|
||||
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
||||
pad,
|
||||
!value && "text-muted-foreground italic",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value && multiline ? (
|
||||
<MarkdownBody>{value}</MarkdownBody>
|
||||
) : (
|
||||
value || placeholder
|
||||
)}
|
||||
{value || placeholder}
|
||||
</DisplayTag>
|
||||
);
|
||||
}
|
||||
|
||||
889
ui/src/components/IssueDocumentsSection.tsx
Normal file
889
ui/src/components/IssueDocumentsSection.tsx
Normal file
@@ -0,0 +1,889 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
|
||||
type DraftState = {
|
||||
key: string;
|
||||
title: string;
|
||||
body: string;
|
||||
baseRevisionId: string | null;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
type DocumentConflictState = {
|
||||
key: string;
|
||||
serverDocument: IssueDocument;
|
||||
localDraft: DraftState;
|
||||
showRemote: boolean;
|
||||
};
|
||||
|
||||
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`;
|
||||
|
||||
function loadFoldedDocumentKeys(issueId: string) {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getFoldedDocumentsStorageKey(issueId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
||||
}
|
||||
|
||||
function renderBody(body: string, className?: string) {
|
||||
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||
}
|
||||
|
||||
function isPlanKey(key: string) {
|
||||
return key.trim().toLowerCase() === "plan";
|
||||
}
|
||||
|
||||
function titlesMatchKey(title: string | null | undefined, key: string) {
|
||||
return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isDocumentConflictError(error: unknown) {
|
||||
return error instanceof ApiError && error.status === 409;
|
||||
}
|
||||
|
||||
function downloadDocumentFile(key: string, body: string) {
|
||||
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${key}.md`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
issue,
|
||||
canDeleteDocuments,
|
||||
mentions,
|
||||
imageUploadHandler,
|
||||
extraActions,
|
||||
}: {
|
||||
issue: Issue;
|
||||
canDeleteDocuments: boolean;
|
||||
mentions?: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
extraActions?: ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
||||
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
|
||||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasScrolledToHashRef = useRef(false);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
|
||||
const { data: documents } = useQuery({
|
||||
queryKey: queryKeys.issues.documents(issue.id),
|
||||
queryFn: () => issuesApi.listDocuments(issue.id),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||
};
|
||||
|
||||
const upsertDocument = useMutation({
|
||||
mutationFn: async (nextDraft: DraftState) =>
|
||||
issuesApi.upsertDocument(issue.id, nextDraft.key, {
|
||||
title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null,
|
||||
format: "markdown",
|
||||
body: nextDraft.body,
|
||||
baseRevisionId: nextDraft.baseRevisionId,
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteDocument = useMutation({
|
||||
mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setConfirmDeleteKey(null);
|
||||
invalidateIssueDocuments();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete document");
|
||||
},
|
||||
});
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
return [...(documents ?? [])].sort((a, b) => {
|
||||
if (a.key === "plan" && b.key !== "plan") return -1;
|
||||
if (a.key !== "plan" && b.key === "plan") return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}, [documents]);
|
||||
|
||||
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
|
||||
const isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument;
|
||||
const newDocumentKeyError =
|
||||
draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
|
||||
? "Use lowercase letters, numbers, -, or _, and start with a letter or number."
|
||||
: null;
|
||||
|
||||
const resetAutosaveState = useCallback(() => {
|
||||
setAutosaveDocumentKey(null);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const markDocumentDirty = useCallback((key: string) => {
|
||||
setAutosaveDocumentKey(key);
|
||||
markDirty();
|
||||
}, [markDirty]);
|
||||
|
||||
const beginNewDocument = () => {
|
||||
resetAutosaveState();
|
||||
setDocumentConflict(null);
|
||||
setDraft({
|
||||
key: "",
|
||||
title: "",
|
||||
body: "",
|
||||
baseRevisionId: null,
|
||||
isNew: true,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const beginEdit = (key: string) => {
|
||||
const doc = sortedDocuments.find((entry) => entry.key === key);
|
||||
if (!doc) return;
|
||||
const conflictedDraft = documentConflict?.key === key ? documentConflict.localDraft : null;
|
||||
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
||||
resetAutosaveState();
|
||||
setDocumentConflict((current) => current?.key === key ? current : null);
|
||||
setDraft({
|
||||
key: conflictedDraft?.key ?? doc.key,
|
||||
title: conflictedDraft?.title ?? doc.title ?? "",
|
||||
body: conflictedDraft?.body ?? doc.body,
|
||||
baseRevisionId: conflictedDraft?.baseRevisionId ?? doc.latestRevisionId,
|
||||
isNew: false,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const cancelDraft = () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
resetAutosaveState();
|
||||
setDocumentConflict(null);
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const commitDraft = useCallback(async (
|
||||
currentDraft: DraftState | null,
|
||||
options?: { clearAfterSave?: boolean; trackAutosave?: boolean; overrideConflict?: boolean },
|
||||
) => {
|
||||
if (!currentDraft || upsertDocument.isPending) return false;
|
||||
const normalizedKey = currentDraft.key.trim().toLowerCase();
|
||||
const normalizedBody = currentDraft.body.trim();
|
||||
const normalizedTitle = currentDraft.title.trim();
|
||||
const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null;
|
||||
|
||||
if (activeConflict && !options?.overrideConflict) {
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!normalizedKey || !normalizedBody) {
|
||||
if (currentDraft.isNew) {
|
||||
setError("Document key and body are required");
|
||||
} else if (!normalizedBody) {
|
||||
setError("Document body cannot be empty");
|
||||
}
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DOCUMENT_KEY_PATTERN.test(normalizedKey)) {
|
||||
setError("Document key must start with a letter or number and use only lowercase letters, numbers, -, or _.");
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = sortedDocuments.find((doc) => doc.key === normalizedKey);
|
||||
if (
|
||||
!currentDraft.isNew &&
|
||||
existing &&
|
||||
existing.body === currentDraft.body &&
|
||||
(existing.title ?? "") === currentDraft.title
|
||||
) {
|
||||
if (options?.clearAfterSave) {
|
||||
setDraft((value) => (value?.key === normalizedKey ? null : value));
|
||||
}
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const saved = await upsertDocument.mutateAsync({
|
||||
...currentDraft,
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
baseRevisionId: options?.overrideConflict
|
||||
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
|
||||
: currentDraft.baseRevisionId,
|
||||
});
|
||||
setError(null);
|
||||
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
|
||||
setDraft((value) => {
|
||||
if (!value || value.key !== normalizedKey) return value;
|
||||
if (options?.clearAfterSave) return null;
|
||||
return {
|
||||
key: saved.key,
|
||||
title: saved.title ?? "",
|
||||
body: saved.body,
|
||||
baseRevisionId: saved.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
invalidateIssueDocuments();
|
||||
};
|
||||
|
||||
try {
|
||||
if (options?.trackAutosave) {
|
||||
setAutosaveDocumentKey(normalizedKey);
|
||||
await runSave(save);
|
||||
} else {
|
||||
await save();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isDocumentConflictError(err)) {
|
||||
try {
|
||||
const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey);
|
||||
setDocumentConflict({
|
||||
key: normalizedKey,
|
||||
serverDocument: latestDocument,
|
||||
localDraft: {
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
baseRevisionId: currentDraft.baseRevisionId,
|
||||
isNew: false,
|
||||
},
|
||||
showRemote: true,
|
||||
});
|
||||
setFoldedDocumentKeys((current) => current.filter((key) => key !== normalizedKey));
|
||||
setError(null);
|
||||
resetAutosaveState();
|
||||
return false;
|
||||
} catch {
|
||||
setError("Document changed remotely and the latest version could not be loaded");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||
return false;
|
||||
}
|
||||
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]);
|
||||
|
||||
const reloadDocumentFromServer = useCallback((key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
const serverDocument = documentConflict.serverDocument;
|
||||
setDraft({
|
||||
key: serverDocument.key,
|
||||
title: serverDocument.title ?? "",
|
||||
body: serverDocument.body,
|
||||
baseRevisionId: serverDocument.latestRevisionId,
|
||||
isNew: false,
|
||||
});
|
||||
setDocumentConflict(null);
|
||||
resetAutosaveState();
|
||||
setError(null);
|
||||
}, [documentConflict, resetAutosaveState]);
|
||||
|
||||
const overwriteDocumentFromDraft = useCallback(async (key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
const sourceDraft =
|
||||
draft && draft.key === key && !draft.isNew
|
||||
? draft
|
||||
: documentConflict.localDraft;
|
||||
await commitDraft(
|
||||
{
|
||||
...sourceDraft,
|
||||
baseRevisionId: documentConflict.serverDocument.latestRevisionId,
|
||||
},
|
||||
{
|
||||
clearAfterSave: false,
|
||||
trackAutosave: true,
|
||||
overrideConflict: true,
|
||||
},
|
||||
);
|
||||
}, [commitDraft, documentConflict, draft]);
|
||||
|
||||
const keepConflictedDraft = useCallback((key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
setDraft(documentConflict.localDraft);
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === key
|
||||
? { ...current, showRemote: false }
|
||||
: current,
|
||||
);
|
||||
setError(null);
|
||||
}, [documentConflict]);
|
||||
|
||||
const copyDocumentBody = useCallback(async (key: string, body: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(body);
|
||||
setCopiedDocumentKey(key);
|
||||
if (copiedDocumentTimerRef.current) {
|
||||
clearTimeout(copiedDocumentTimerRef.current);
|
||||
}
|
||||
copiedDocumentTimerRef.current = setTimeout(() => {
|
||||
setCopiedDocumentKey((current) => current === key ? null : current);
|
||||
}, 1400);
|
||||
} catch {
|
||||
setError("Could not copy document");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
await commitDraft(draft, { clearAfterSave: true, trackAutosave: true });
|
||||
};
|
||||
|
||||
const handleDraftKeyDown = async (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelDraft();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
await commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id));
|
||||
}, [issue.id]);
|
||||
|
||||
useEffect(() => {
|
||||
hasScrolledToHashRef.current = false;
|
||||
}, [issue.id, location.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
const validKeys = new Set(sortedDocuments.map((doc) => doc.key));
|
||||
setFoldedDocumentKeys((current) => {
|
||||
const next = current.filter((key) => validKeys.has(key));
|
||||
if (next.length !== current.length) {
|
||||
saveFoldedDocumentKeys(issue.id, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [issue.id, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
saveFoldedDocumentKeys(issue.id, foldedDocumentKeys);
|
||||
}, [foldedDocumentKeys, issue.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentConflict) return;
|
||||
const latest = sortedDocuments.find((doc) => doc.key === documentConflict.key);
|
||||
if (!latest || latest.latestRevisionId === documentConflict.serverDocument.latestRevisionId) return;
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === latest.key
|
||||
? { ...current, serverDocument: latest }
|
||||
: current,
|
||||
);
|
||||
}, [documentConflict, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!hash.startsWith("#document-")) return;
|
||||
const documentKey = decodeURIComponent(hash.slice("#document-".length));
|
||||
const targetExists = sortedDocuments.some((doc) => doc.key === documentKey)
|
||||
|| (documentKey === "plan" && Boolean(issue.legacyPlanDocument));
|
||||
if (!targetExists || hasScrolledToHashRef.current) return;
|
||||
setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey));
|
||||
const element = document.getElementById(`document-${documentKey}`);
|
||||
if (!element) return;
|
||||
hasScrolledToHashRef.current = true;
|
||||
setHighlightDocumentKey(documentKey);
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [issue.legacyPlanDocument, location.hash, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
if (copiedDocumentTimerRef.current) {
|
||||
clearTimeout(copiedDocumentTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || draft.isNew) return;
|
||||
if (documentConflict?.key === draft.key) return;
|
||||
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
||||
if (!existing) return;
|
||||
const hasChanges =
|
||||
existing.body !== draft.body ||
|
||||
(existing.title ?? "") !== draft.title;
|
||||
if (!hasChanges) {
|
||||
if (autosaveState !== "saved") {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDocumentDirty(draft.key);
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||
}, DOCUMENT_AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
||||
|
||||
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
|
||||
const documentBodyPaddingClassName = "";
|
||||
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||
const toggleFoldedDocument = (key: string) => {
|
||||
setFoldedDocumentKeys((current) =>
|
||||
current.includes(key)
|
||||
? current.filter((entry) => entry !== key)
|
||||
: [...current, key],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{isEmpty && !draft?.isNew ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
{draft?.isNew && (
|
||||
<div
|
||||
className="space-y-3 rounded-lg border border-border bg-accent/10 p-3"
|
||||
onBlurCapture={handleDraftBlur}
|
||||
onKeyDown={handleDraftKeyDown}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft.key}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => current ? { ...current, key: event.target.value.toLowerCase() } : current)
|
||||
}
|
||||
placeholder="Document key"
|
||||
/>
|
||||
{newDocumentKeyError && (
|
||||
<p className="text-xs text-destructive">{newDocumentKeyError}</p>
|
||||
)}
|
||||
{!isPlanKey(draft.key) && (
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => current ? { ...current, title: event.target.value } : current)
|
||||
}
|
||||
placeholder="Optional title"
|
||||
/>
|
||||
)}
|
||||
<MarkdownEditor
|
||||
value={draft.body}
|
||||
onChange={(body) =>
|
||||
setDraft((current) => current ? { ...current, body } : current)
|
||||
}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName="min-h-[220px] text-[15px] leading-7"
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={cancelDraft}>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||
disabled={upsertDocument.isPending}
|
||||
>
|
||||
{upsertDocument.isPending ? "Saving..." : "Create document"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasRealPlan && issue.legacyPlanDocument ? (
|
||||
<div
|
||||
id="document-plan"
|
||||
className={cn(
|
||||
"rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 transition-colors duration-1000",
|
||||
highlightDocumentKey === "plan" && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-amber-600" />
|
||||
<span className="rounded-full border border-amber-500/30 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300">
|
||||
PLAN
|
||||
</span>
|
||||
</div>
|
||||
<div className={documentBodyPaddingClassName}>
|
||||
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedDocuments.map((doc) => {
|
||||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
||||
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
id={`document-${doc.key}`}
|
||||
className={cn(
|
||||
"rounded-lg border border-border p-3 transition-colors duration-1000",
|
||||
highlightDocumentKey === doc.key && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={() => toggleFoldedDocument(doc.key)}
|
||||
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
|
||||
aria-expanded={!isFolded}
|
||||
>
|
||||
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{doc.key}
|
||||
</span>
|
||||
<a
|
||||
href={`#document-${encodeURIComponent(doc.key)}`}
|
||||
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||
</a>
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn(
|
||||
"text-muted-foreground transition-colors",
|
||||
copiedDocumentKey === doc.key && "text-foreground",
|
||||
)}
|
||||
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
||||
onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)}
|
||||
>
|
||||
{copiedDocumentKey === doc.key ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
title="Document actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download document
|
||||
</DropdownMenuItem>
|
||||
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
||||
{canDeleteDocuments ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmDeleteKey(doc.key)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete document
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isFolded ? (
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onFocusCapture={() => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}}
|
||||
onBlurCapture={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftBlur(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftKeyDown(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeConflict && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-amber-200">Out of date</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This document changed while you were editing. Your local draft is preserved and autosave is paused.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === doc.key
|
||||
? { ...current, showRemote: !current.showRemote }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
>
|
||||
{activeConflict.showRemote ? "Hide remote" : "Review remote"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => keepConflictedDraft(doc.key)}
|
||||
>
|
||||
Keep my draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => reloadDocumentFromServer(doc.key)}
|
||||
>
|
||||
Reload remote
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void overwriteDocumentFromDraft(doc.key)}
|
||||
disabled={upsertDocument.isPending}
|
||||
>
|
||||
{upsertDocument.isPending ? "Saving..." : "Overwrite remote"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{activeConflict.showRemote && (
|
||||
<div className="mt-3 rounded-md border border-border/70 bg-background/60 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Remote revision {activeConflict.serverDocument.latestRevisionNumber}</span>
|
||||
<span>•</span>
|
||||
<span>updated {relativeTime(activeConflict.serverDocument.updatedAt)}</span>
|
||||
</div>
|
||||
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
|
||||
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
|
||||
) : null}
|
||||
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeDraft && !isPlanKey(doc.key) && (
|
||||
<Input
|
||||
value={activeDraft.title}
|
||||
onChange={(event) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => current ? { ...current, title: event.target.value } : current);
|
||||
}}
|
||||
placeholder="Optional title"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||
activeDraft ? "" : "hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
<MarkdownEditor
|
||||
value={activeDraft?.body ?? doc.body}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
<span
|
||||
className={`text-[11px] transition-opacity duration-150 ${
|
||||
activeConflict
|
||||
? "text-amber-300"
|
||||
: autosaveState === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{activeDraft
|
||||
? activeConflict
|
||||
? "Out of date"
|
||||
: autosaveDocumentKey === doc.key
|
||||
? autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: ""
|
||||
: ""
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{confirmDeleteKey === doc.key && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
Delete this document? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmDeleteKey(null)}
|
||||
disabled={deleteDocument.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteDocument.mutate(doc.key)}
|
||||
disabled={deleteDocument.isPending}
|
||||
>
|
||||
{deleteDocument.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
@@ -255,14 +256,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
: null;
|
||||
const userLabel = (userId: string | null | undefined) =>
|
||||
userId
|
||||
? userId === "local-board"
|
||||
? "Board"
|
||||
: currentUserId && userId === currentUserId
|
||||
? "Me"
|
||||
: userId.slice(0, 5)
|
||||
: null;
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
|
||||
@@ -398,7 +392,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{issue.createdByUserId && (
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
|
||||
setAssigneeOpen(false);
|
||||
}}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
Assign to me
|
||||
</button>
|
||||
)}
|
||||
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
@@ -410,7 +419,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
}}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
|
||||
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
|
||||
</button>
|
||||
)}
|
||||
{sortedAgents
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { formatDate, cn } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
@@ -87,11 +89,20 @@ function toggleInArray(arr: string[], value: string): string[] {
|
||||
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
|
||||
}
|
||||
|
||||
function applyFilters(issues: Issue[], state: IssueViewState): Issue[] {
|
||||
function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] {
|
||||
let result = issues;
|
||||
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status));
|
||||
if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority));
|
||||
if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId));
|
||||
if (state.assignees.length > 0) {
|
||||
result = result.filter((issue) => {
|
||||
for (const assignee of state.assignees) {
|
||||
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
|
||||
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
|
||||
if (issue.assigneeAgentId === assignee) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
|
||||
return result;
|
||||
}
|
||||
@@ -165,6 +176,11 @@ export function IssuesList({
|
||||
}: IssuesListProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
|
||||
// Scope the storage key per company so folding/view state is independent across companies.
|
||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||
@@ -224,9 +240,9 @@ export function IssuesList({
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||
const filteredByControls = applyFilters(sourceIssues, viewState);
|
||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||
return sortIssues(filteredByControls, viewState);
|
||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch]);
|
||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
@@ -253,13 +269,21 @@ export function IssuesList({
|
||||
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
||||
}
|
||||
// assignee
|
||||
const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned");
|
||||
const groups = groupBy(
|
||||
filtered,
|
||||
(issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"),
|
||||
);
|
||||
return Object.keys(groups).map((key) => ({
|
||||
key,
|
||||
label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)),
|
||||
label:
|
||||
key === "__unassigned"
|
||||
? "Unassigned"
|
||||
: key.startsWith("__user:")
|
||||
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
|
||||
: (agentName(key) ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
|
||||
|
||||
const newIssueDefaults = (groupKey?: string) => {
|
||||
const defaults: Record<string, string> = {};
|
||||
@@ -267,13 +291,16 @@ export function IssuesList({
|
||||
if (groupKey) {
|
||||
if (viewState.groupBy === "status") defaults.status = groupKey;
|
||||
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
||||
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey;
|
||||
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
|
||||
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||
else defaults.assigneeAgentId = groupKey;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const assignIssue = (issueId: string, assigneeAgentId: string | null) => {
|
||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null });
|
||||
const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
||||
setAssigneePickerIssueId(null);
|
||||
setAssigneeSearch("");
|
||||
};
|
||||
@@ -419,22 +446,37 @@ export function IssuesList({
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
{agents && agents.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Assignee</span>
|
||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{agents.map((agent) => (
|
||||
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes(agent.id)}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
|
||||
/>
|
||||
<span className="text-sm">{agent.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Assignee</span>
|
||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes("__unassigned")}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
|
||||
/>
|
||||
<span className="text-sm">No assignee</span>
|
||||
</label>
|
||||
{currentUserId && (
|
||||
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes("__me")}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
|
||||
/>
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">Me</span>
|
||||
</label>
|
||||
)}
|
||||
{(agents ?? []).map((agent) => (
|
||||
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes(agent.id)}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
|
||||
/>
|
||||
<span className="text-sm">{agent.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{labels && labels.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
@@ -683,6 +725,13 @@ export function IssuesList({
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
@@ -701,7 +750,7 @@ export function IssuesList({
|
||||
>
|
||||
<input
|
||||
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Search agents..."
|
||||
placeholder="Search assignees..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
@@ -710,16 +759,32 @@ export function IssuesList({
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
!issue.assigneeAgentId && "bg-accent",
|
||||
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null);
|
||||
assignIssue(issue.id, null, null);
|
||||
}}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, currentUserId);
|
||||
}}
|
||||
>
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>Me</span>
|
||||
</button>
|
||||
)}
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
@@ -737,7 +802,7 @@ export function IssuesList({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id);
|
||||
assignIssue(issue.id, agent.id, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
@@ -15,6 +14,7 @@ import { NewGoalDialog } from "./NewGoalDialog";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -23,6 +23,7 @@ import { useTheme } from "../context/ThemeContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
@@ -37,6 +38,7 @@ export function Layout() {
|
||||
loading: companiesLoading,
|
||||
selectedCompany,
|
||||
selectedCompanyId,
|
||||
selectionSource,
|
||||
setSelectedCompanyId,
|
||||
} = useCompany();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
@@ -89,7 +91,13 @@ export function Layout() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCompanyId !== matchedCompany.id) {
|
||||
if (
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource,
|
||||
selectedCompanyId,
|
||||
routeCompanyId: matchedCompany.id,
|
||||
})
|
||||
) {
|
||||
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
|
||||
}
|
||||
}, [
|
||||
@@ -100,6 +108,7 @@ export function Layout() {
|
||||
location.pathname,
|
||||
location.search,
|
||||
navigate,
|
||||
selectionSource,
|
||||
selectedCompanyId,
|
||||
setSelectedCompanyId,
|
||||
]);
|
||||
@@ -215,7 +224,7 @@ export function Layout() {
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
||||
isMobile ? "min-h-dvh" : "flex h-dvh overflow-hidden",
|
||||
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<a
|
||||
@@ -224,139 +233,148 @@ export function Layout() {
|
||||
>
|
||||
Skip to Main Content
|
||||
</a>
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40 bg-black/50"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
)}
|
||||
<WorktreeBanner />
|
||||
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40 bg-black/50"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
|
||||
{isMobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<CompanyRail />
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarNavItem
|
||||
to="/docs"
|
||||
label="Documentation"
|
||||
icon={BookOpen}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to="/instance/settings"
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col shrink-0 h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<CompanyRail />
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[width] duration-100 ease-out",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarNavItem
|
||||
to="/docs"
|
||||
label="Documentation"
|
||||
icon={BookOpen}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to="/instance/settings"
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||
)}
|
||||
>
|
||||
<BreadcrumbBar />
|
||||
</div>
|
||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
|
||||
{isMobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 p-4 md:p-6",
|
||||
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
||||
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
{hasUnknownCompanyPrefix ? (
|
||||
<NotFoundPage
|
||||
scope="invalid_company_prefix"
|
||||
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
|
||||
/>
|
||||
) : (
|
||||
<Outlet />
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<CompanyRail />
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to="/instance/settings"
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<CompanyRail />
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-[width] duration-100 ease-out",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to="/instance/settings"
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||
)}
|
||||
</main>
|
||||
<PropertiesPanel />
|
||||
>
|
||||
<BreadcrumbBar />
|
||||
</div>
|
||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"flex-1 p-4 md:p-6",
|
||||
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
||||
)}
|
||||
>
|
||||
{hasUnknownCompanyPrefix ? (
|
||||
<NotFoundPage
|
||||
scope="invalid_company_prefix"
|
||||
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
|
||||
/>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</main>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -11,6 +11,12 @@ import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
currentUserAssigneeOption,
|
||||
parseAssigneeValue,
|
||||
} from "../lib/assignees";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -35,7 +41,9 @@ import {
|
||||
Tag,
|
||||
Calendar,
|
||||
Paperclip,
|
||||
FileText,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
@@ -63,7 +71,8 @@ interface IssueDraft {
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeId: string;
|
||||
assigneeValue: string;
|
||||
assigneeId?: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId?: string;
|
||||
assigneeModelOverride: string;
|
||||
@@ -74,7 +83,16 @@ interface IssueDraft {
|
||||
useIsolatedExecutionWorkspace?: boolean;
|
||||
}
|
||||
|
||||
type StagedIssueFile = {
|
||||
id: string;
|
||||
file: File;
|
||||
kind: "document" | "attachment";
|
||||
documentKey?: string;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||
const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown";
|
||||
|
||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||
claude_local: [
|
||||
@@ -153,6 +171,59 @@ function clearDraft() {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
}
|
||||
|
||||
function isTextDocumentFile(file: File) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown") ||
|
||||
name.endsWith(".txt") ||
|
||||
file.type === "text/markdown" ||
|
||||
file.type === "text/plain"
|
||||
);
|
||||
}
|
||||
|
||||
function fileBaseName(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
function slugifyDocumentKey(input: string) {
|
||||
const slug = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "document";
|
||||
}
|
||||
|
||||
function titleizeFilename(input: string) {
|
||||
return input
|
||||
.split(/[-_ ]+/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function createUniqueDocumentKey(baseKey: string, stagedFiles: StagedIssueFile[]) {
|
||||
const existingKeys = new Set(
|
||||
stagedFiles
|
||||
.filter((file) => file.kind === "document")
|
||||
.map((file) => file.documentKey)
|
||||
.filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
if (!existingKeys.has(baseKey)) return baseKey;
|
||||
let suffix = 2;
|
||||
while (existingKeys.has(`${baseKey}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
return `${baseKey}-${suffix}`;
|
||||
}
|
||||
|
||||
function formatFileSize(file: File) {
|
||||
if (file.size < 1024) return `${file.size} B`;
|
||||
if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`;
|
||||
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const statuses = [
|
||||
{ value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault },
|
||||
{ value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault },
|
||||
@@ -211,11 +282,12 @@ export function NewIssueDialog() {
|
||||
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { enabled: showExperimentalWorkspaceUi } = useExperimentalWorkspacesEnabled();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("todo");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState("");
|
||||
const [assigneeValue, setAssigneeValue] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
@@ -226,6 +298,8 @@ export function NewIssueDialog() {
|
||||
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
|
||||
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
||||
|
||||
@@ -238,7 +312,7 @@ export function NewIssueDialog() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [companyOpen, setCompanyOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stageFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
@@ -278,7 +352,11 @@ export function NewIssueDialog() {
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
|
||||
const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]);
|
||||
const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId;
|
||||
const selectedAssigneeUserId = selectedAssignee.assigneeUserId;
|
||||
|
||||
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId)?.adapterType ?? null;
|
||||
const supportsAssigneeOverrides = Boolean(
|
||||
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
||||
);
|
||||
@@ -316,11 +394,49 @@ export function NewIssueDialog() {
|
||||
});
|
||||
|
||||
const createIssue = useMutation({
|
||||
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
|
||||
issuesApi.create(companyId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
|
||||
mutationFn: async ({
|
||||
companyId,
|
||||
stagedFiles: pendingStagedFiles,
|
||||
...data
|
||||
}: { companyId: string; stagedFiles: StagedIssueFile[] } & Record<string, unknown>) => {
|
||||
const issue = await issuesApi.create(companyId, data);
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const stagedFile of pendingStagedFiles) {
|
||||
try {
|
||||
if (stagedFile.kind === "document") {
|
||||
const body = await stagedFile.file.text();
|
||||
await issuesApi.upsertDocument(issue.id, stagedFile.documentKey ?? "document", {
|
||||
title: stagedFile.documentKey === "plan" ? null : stagedFile.title ?? null,
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: null,
|
||||
});
|
||||
} else {
|
||||
await issuesApi.uploadAttachment(companyId, issue.id, stagedFile.file);
|
||||
}
|
||||
} catch {
|
||||
failures.push(stagedFile.file.name);
|
||||
}
|
||||
}
|
||||
|
||||
return { issue, companyId, failures };
|
||||
},
|
||||
onSuccess: ({ issue, companyId, failures }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
if (failures.length > 0) {
|
||||
const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim();
|
||||
const issueRef = issue.identifier ?? issue.id;
|
||||
pushToast({
|
||||
title: `Created ${issueRef} with upload warnings`,
|
||||
body: `${failures.length} staged ${failures.length === 1 ? "file" : "files"} could not be added.`,
|
||||
tone: "warn",
|
||||
action: prefix
|
||||
? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
clearDraft();
|
||||
reset();
|
||||
closeNewIssue();
|
||||
@@ -353,7 +469,7 @@ export function NewIssueDialog() {
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
assigneeId,
|
||||
assigneeValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
@@ -367,7 +483,7 @@ export function NewIssueDialog() {
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
assigneeId,
|
||||
assigneeValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
@@ -395,7 +511,7 @@ export function NewIssueDialog() {
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
@@ -409,7 +525,11 @@ export function NewIssueDialog() {
|
||||
setDescription(draft.description);
|
||||
setStatus(draft.status || "todo");
|
||||
setPriority(draft.priority);
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
|
||||
setAssigneeValue(
|
||||
newIssueDefaults.assigneeAgentId || newIssueDefaults.assigneeUserId
|
||||
? assigneeValueFromSelection(newIssueDefaults)
|
||||
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
|
||||
);
|
||||
setProjectId(restoredProjectId);
|
||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
@@ -428,7 +548,7 @@ export function NewIssueDialog() {
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
@@ -470,7 +590,7 @@ export function NewIssueDialog() {
|
||||
setDescription("");
|
||||
setStatus("todo");
|
||||
setPriority("");
|
||||
setAssigneeId("");
|
||||
setAssigneeValue("");
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeOptionsOpen(false);
|
||||
@@ -481,6 +601,8 @@ export function NewIssueDialog() {
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setStagedFiles([]);
|
||||
setIsFileDragOver(false);
|
||||
setCompanyOpen(false);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
}
|
||||
@@ -488,7 +610,7 @@ export function NewIssueDialog() {
|
||||
function handleCompanyChange(companyId: string) {
|
||||
if (companyId === effectiveCompanyId) return;
|
||||
setDialogCompanyId(companyId);
|
||||
setAssigneeId("");
|
||||
setAssigneeValue("");
|
||||
setProjectId("");
|
||||
setProjectWorkspaceId("");
|
||||
setAssigneeModelOverride("");
|
||||
@@ -528,11 +650,13 @@ export function NewIssueDialog() {
|
||||
: null;
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
stagedFiles,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
priority: priority || "medium",
|
||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||
@@ -551,25 +675,75 @@ export function NewIssueDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachImage(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
const name = file.name || "image";
|
||||
setDescription((prev) => {
|
||||
const suffix = ``;
|
||||
return prev ? `${prev}\n\n${suffix}` : suffix;
|
||||
});
|
||||
} finally {
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
function stageFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
setStagedFiles((current) => {
|
||||
const next = [...current];
|
||||
for (const file of files) {
|
||||
if (isTextDocumentFile(file)) {
|
||||
const baseName = fileBaseName(file.name);
|
||||
const documentKey = createUniqueDocumentKey(slugifyDocumentKey(baseName), next);
|
||||
next.push({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${documentKey}`,
|
||||
file,
|
||||
kind: "document",
|
||||
documentKey,
|
||||
title: titleizeFilename(baseName),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}`,
|
||||
file,
|
||||
kind: "attachment",
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleStageFilesPicked(evt: ChangeEvent<HTMLInputElement>) {
|
||||
stageFiles(Array.from(evt.target.files ?? []));
|
||||
if (stageFileInputRef.current) {
|
||||
stageFileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
|
||||
function handleFileDragEnter(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.types.includes("Files")) return;
|
||||
evt.preventDefault();
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleFileDragOver(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.types.includes("Files")) return;
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = "copy";
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleFileDragLeave(evt: DragEvent<HTMLDivElement>) {
|
||||
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
||||
setIsFileDragOver(false);
|
||||
}
|
||||
|
||||
function handleFileDrop(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.files.length) return;
|
||||
evt.preventDefault();
|
||||
setIsFileDragOver(false);
|
||||
stageFiles(Array.from(evt.dataTransfer.files));
|
||||
}
|
||||
|
||||
function removeStagedFile(id: string) {
|
||||
setStagedFiles((current) => current.filter((file) => file.id !== id));
|
||||
}
|
||||
|
||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0 || stagedFiles.length > 0;
|
||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||
const currentAssignee = selectedAssigneeAgentId
|
||||
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
|
||||
: null;
|
||||
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
||||
const currentProjectWorkspaces = currentProject?.workspaces ?? [];
|
||||
const currentProjectExecutionWorkspacePolicy = showExperimentalWorkspaceUi
|
||||
@@ -595,16 +769,18 @@ export function NewIssueDialog() {
|
||||
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
|
||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
sortAgentsByRecency(
|
||||
() => [
|
||||
...currentUserAssigneeOption(currentUserId),
|
||||
...sortAgentsByRecency(
|
||||
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
||||
recentAssigneeIds,
|
||||
).map((agent) => ({
|
||||
id: agent.id,
|
||||
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
||||
label: agent.name,
|
||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||
})),
|
||||
[agents, recentAssigneeIds],
|
||||
],
|
||||
[agents, currentUserId, recentAssigneeIds],
|
||||
);
|
||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
@@ -620,6 +796,8 @@ export function NewIssueDialog() {
|
||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||
const createIssueErrorMessage =
|
||||
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
|
||||
const stagedDocuments = stagedFiles.filter((file) => file.kind === "document");
|
||||
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
||||
|
||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||
setProjectId(nextProjectId);
|
||||
@@ -805,7 +983,16 @@ export function NewIssueDialog() {
|
||||
}
|
||||
if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
assigneeSelectorRef.current?.focus();
|
||||
if (assigneeValue) {
|
||||
// Assignee already set — skip to project or description
|
||||
if (projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
assigneeSelectorRef.current?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
@@ -818,33 +1005,49 @@ export function NewIssueDialog() {
|
||||
<span>For</span>
|
||||
<InlineEntitySelector
|
||||
ref={assigneeSelectorRef}
|
||||
value={assigneeId}
|
||||
value={assigneeValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Assignee"
|
||||
disablePortal
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={(id) => { if (id) trackRecentAssignee(id); setAssigneeId(id); }}
|
||||
onChange={(value) => {
|
||||
const nextAssignee = parseAssigneeValue(value);
|
||||
if (nextAssignee.assigneeAgentId) {
|
||||
trackRecentAssignee(nextAssignee.assigneeAgentId);
|
||||
}
|
||||
setAssigneeValue(value);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
projectSelectorRef.current?.focus();
|
||||
if (projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
renderTriggerValue={(option) =>
|
||||
option && currentAssignee ? (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
option ? (
|
||||
currentAssignee ? (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">Assignee</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const assignee = (agents ?? []).find((agent) => agent.id === option.id);
|
||||
const assignee = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<AgentIcon icon={assignee?.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
@@ -1030,20 +1233,103 @@ export function NewIssueDialog() {
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}>
|
||||
<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
|
||||
className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
<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>
|
||||
<div className="space-y-2">
|
||||
{stagedDocuments.map((file) => (
|
||||
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{file.documentKey}
|
||||
</span>
|
||||
<span className="truncate text-sm">{file.file.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<span>{file.title || file.file.name}</span>
|
||||
<span>•</span>
|
||||
<span>{formatFileSize(file.file)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => removeStagedFile(file.id)}
|
||||
disabled={createIssue.isPending}
|
||||
title="Remove document"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stagedAttachments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Attachments</div>
|
||||
<div className="space-y-2">
|
||||
{stagedAttachments.map((file) => (
|
||||
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{file.file.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{file.file.type || "application/octet-stream"} • {formatFileSize(file.file)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => removeStagedFile(file.id)}
|
||||
disabled={createIssue.isPending}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Property chips bar */}
|
||||
@@ -1113,21 +1399,21 @@ export function NewIssueDialog() {
|
||||
Labels
|
||||
</button>
|
||||
|
||||
{/* Attach image chip */}
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
ref={stageFileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
accept={STAGED_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={handleAttachImage}
|
||||
onChange={handleStageFilesPicked}
|
||||
multiple
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={uploadDescriptionImage.isPending}
|
||||
onClick={() => stageFileInputRef.current?.click()}
|
||||
disabled={createIssue.isPending}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{uploadDescriptionImage.isPending ? "Uploading..." : "Image"}
|
||||
Upload
|
||||
</button>
|
||||
|
||||
{/* More (dates) */}
|
||||
|
||||
@@ -494,23 +494,41 @@ export function OnboardingWizard() {
|
||||
}
|
||||
|
||||
async function handleStep3Next() {
|
||||
if (!createdCompanyId || !createdAgentId) return;
|
||||
setError(null);
|
||||
setStep(4);
|
||||
}
|
||||
|
||||
async function handleLaunch() {
|
||||
if (!createdCompanyId || !createdAgentId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const issue = await issuesApi.create(createdCompanyId, {
|
||||
title: taskTitle.trim(),
|
||||
...(taskDescription.trim()
|
||||
? { description: taskDescription.trim() }
|
||||
: {}),
|
||||
assigneeAgentId: createdAgentId,
|
||||
status: "todo"
|
||||
});
|
||||
setCreatedIssueRef(issue.identifier ?? issue.id);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.list(createdCompanyId)
|
||||
});
|
||||
setStep(4);
|
||||
let issueRef = createdIssueRef;
|
||||
if (!issueRef) {
|
||||
const issue = await issuesApi.create(createdCompanyId, {
|
||||
title: taskTitle.trim(),
|
||||
...(taskDescription.trim()
|
||||
? { description: taskDescription.trim() }
|
||||
: {}),
|
||||
assigneeAgentId: createdAgentId,
|
||||
status: "todo"
|
||||
});
|
||||
issueRef = issue.identifier ?? issue.id;
|
||||
setCreatedIssueRef(issueRef);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.list(createdCompanyId)
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedCompanyId(createdCompanyId);
|
||||
reset();
|
||||
closeOnboarding();
|
||||
navigate(
|
||||
createdCompanyPrefix
|
||||
? `/${createdCompanyPrefix}/issues/${issueRef}`
|
||||
: `/issues/${issueRef}`
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create task");
|
||||
} finally {
|
||||
@@ -518,20 +536,6 @@ export function OnboardingWizard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLaunch() {
|
||||
if (!createdAgentId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
reset();
|
||||
closeOnboarding();
|
||||
if (createdCompanyPrefix) {
|
||||
navigate(`/${createdCompanyPrefix}/dashboard`);
|
||||
return;
|
||||
}
|
||||
navigate("/dashboard");
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
@@ -1175,8 +1179,8 @@ export function OnboardingWizard() {
|
||||
<div>
|
||||
<h3 className="font-medium">Ready to launch</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything is set up. Your assigned task already woke
|
||||
the agent, so you can jump straight to the issue.
|
||||
Everything is set up. Launching now will create the
|
||||
starter task, wake the agent, and open the issue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1291,7 +1295,7 @@ export function OnboardingWizard() {
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Opening..." : "Open Issue"}
|
||||
{loading ? "Creating..." : "Create & Open Issue"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
25
ui/src/components/WorktreeBanner.tsx
Normal file
25
ui/src/components/WorktreeBanner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getWorktreeUiBranding } from "../lib/worktree-branding";
|
||||
|
||||
export function WorktreeBanner() {
|
||||
const branding = getWorktreeUiBranding();
|
||||
if (!branding) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden border-b px-3 py-1.5 text-[11px] font-medium tracking-[0.2em] uppercase"
|
||||
style={{
|
||||
backgroundColor: branding.color,
|
||||
color: branding.textColor,
|
||||
borderColor: `${branding.textColor}22`,
|
||||
boxShadow: `inset 0 -1px 0 ${branding.textColor}18`,
|
||||
backgroundImage: `linear-gradient(90deg, ${branding.textColor}14, transparent 28%, transparent 72%, ${branding.textColor}12), repeating-linear-gradient(135deg, transparent 0 10px, ${branding.textColor}08 10px 20px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0 opacity-70">Worktree</span>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
|
||||
<span className="truncate font-semibold tracking-[0.12em]">{branding.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export const help: Record<string, string> = {
|
||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
|
||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||
promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.",
|
||||
model: "Override the default model used by the adapter.",
|
||||
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
||||
chrome: "Enable Claude's Chrome integration by passing --chrome.",
|
||||
@@ -44,7 +44,7 @@ export const help: Record<string, string> = {
|
||||
args: "Command-line arguments, comma-separated.",
|
||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||
bootstrapPrompt: "Optional prompt prepended on the first run to bootstrap the agent's environment or habits.",
|
||||
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
|
||||
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
|
||||
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
||||
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
||||
|
||||
@@ -12,8 +12,7 @@ import type { Company } from "@paperclipai/shared";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { ApiError } from "../api/client";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
|
||||
import type { CompanySelectionSource } from "../lib/company-selection";
|
||||
type CompanySelectionOptions = { source?: CompanySelectionSource };
|
||||
|
||||
interface CompanyContextValue {
|
||||
|
||||
@@ -5,6 +5,7 @@ interface NewIssueDefaults {
|
||||
priority?: string;
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -369,6 +369,9 @@ function invalidateActivityQueries(
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
||||
}
|
||||
|
||||
72
ui/src/hooks/useAutosaveIndicator.ts
Normal file
72
ui/src/hooks/useAutosaveIndicator.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type AutosaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
const SAVING_DELAY_MS = 250;
|
||||
const SAVED_LINGER_MS = 1600;
|
||||
|
||||
export function useAutosaveIndicator() {
|
||||
const [state, setState] = useState<AutosaveState>("idle");
|
||||
const saveIdRef = useRef(0);
|
||||
const savingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (savingTimerRef.current) {
|
||||
clearTimeout(savingTimerRef.current);
|
||||
savingTimerRef.current = null;
|
||||
}
|
||||
if (clearSavedTimerRef.current) {
|
||||
clearTimeout(clearSavedTimerRef.current);
|
||||
clearSavedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
saveIdRef.current += 1;
|
||||
clearTimers();
|
||||
setState("idle");
|
||||
}, [clearTimers]);
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
clearTimers();
|
||||
setState("idle");
|
||||
}, [clearTimers]);
|
||||
|
||||
const runSave = useCallback(async (save: () => Promise<void>) => {
|
||||
const saveId = saveIdRef.current + 1;
|
||||
saveIdRef.current = saveId;
|
||||
clearTimers();
|
||||
savingTimerRef.current = setTimeout(() => {
|
||||
if (saveIdRef.current === saveId) {
|
||||
setState("saving");
|
||||
}
|
||||
}, SAVING_DELAY_MS);
|
||||
|
||||
try {
|
||||
await save();
|
||||
if (saveIdRef.current !== saveId) return;
|
||||
clearTimers();
|
||||
setState("saved");
|
||||
clearSavedTimerRef.current = setTimeout(() => {
|
||||
if (saveIdRef.current === saveId) {
|
||||
setState("idle");
|
||||
}
|
||||
}, SAVED_LINGER_MS);
|
||||
} catch (error) {
|
||||
if (saveIdRef.current !== saveId) throw error;
|
||||
clearTimers();
|
||||
setState("error");
|
||||
throw error;
|
||||
}
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
state,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
};
|
||||
}
|
||||
71
ui/src/hooks/useCompanyPageMemory.test.ts
Normal file
71
ui/src/hooks/useCompanyPageMemory.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRememberedPathOwnerCompanyId,
|
||||
sanitizeRememberedPathForCompany,
|
||||
} from "../lib/company-page-memory";
|
||||
|
||||
const companies = [
|
||||
{ id: "for", issuePrefix: "FOR" },
|
||||
{ id: "pap", issuePrefix: "PAP" },
|
||||
];
|
||||
|
||||
describe("getRememberedPathOwnerCompanyId", () => {
|
||||
it("uses the route company instead of stale selected-company state for prefixed routes", () => {
|
||||
expect(
|
||||
getRememberedPathOwnerCompanyId({
|
||||
companies,
|
||||
pathname: "/FOR/issues/FOR-1",
|
||||
fallbackCompanyId: "pap",
|
||||
}),
|
||||
).toBe("for");
|
||||
});
|
||||
|
||||
it("skips saving when a prefixed route cannot yet be resolved to a known company", () => {
|
||||
expect(
|
||||
getRememberedPathOwnerCompanyId({
|
||||
companies: [],
|
||||
pathname: "/FOR/issues/FOR-1",
|
||||
fallbackCompanyId: "pap",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the previous company for unprefixed board routes", () => {
|
||||
expect(
|
||||
getRememberedPathOwnerCompanyId({
|
||||
companies,
|
||||
pathname: "/dashboard",
|
||||
fallbackCompanyId: "pap",
|
||||
}),
|
||||
).toBe("pap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRememberedPathForCompany", () => {
|
||||
it("keeps remembered issue paths that belong to the target company", () => {
|
||||
expect(
|
||||
sanitizeRememberedPathForCompany({
|
||||
path: "/issues/PAP-12",
|
||||
companyPrefix: "PAP",
|
||||
}),
|
||||
).toBe("/issues/PAP-12");
|
||||
});
|
||||
|
||||
it("falls back to dashboard for remembered issue identifiers from another company", () => {
|
||||
expect(
|
||||
sanitizeRememberedPathForCompany({
|
||||
path: "/issues/FOR-1",
|
||||
companyPrefix: "PAP",
|
||||
}),
|
||||
).toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("falls back to dashboard when no remembered path exists", () => {
|
||||
expect(
|
||||
sanitizeRememberedPathForCompany({
|
||||
path: null,
|
||||
companyPrefix: "PAP",
|
||||
}),
|
||||
).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||
import {
|
||||
getRememberedPathOwnerCompanyId,
|
||||
isRememberableCompanyPath,
|
||||
sanitizeRememberedPathForCompany,
|
||||
} from "../lib/company-page-memory";
|
||||
|
||||
const STORAGE_KEY = "paperclip.companyPaths";
|
||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
|
||||
|
||||
function getCompanyPaths(): Record<string, string> {
|
||||
try {
|
||||
@@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
|
||||
}
|
||||
|
||||
function isRememberableCompanyPath(path: string): boolean {
|
||||
const pathname = path.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return true;
|
||||
const [root] = segments;
|
||||
if (GLOBAL_SEGMENTS.has(root!)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the last visited page per company and navigates to it on company switch.
|
||||
* Falls back to /dashboard if no page was previously visited for a company.
|
||||
*/
|
||||
export function useCompanyPageMemory() {
|
||||
const { selectedCompanyId, selectedCompany, selectionSource } = useCompany();
|
||||
const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const prevCompanyId = useRef<string | null>(selectedCompanyId);
|
||||
const rememberedPathOwnerCompanyId = useMemo(
|
||||
() =>
|
||||
getRememberedPathOwnerCompanyId({
|
||||
companies,
|
||||
pathname: location.pathname,
|
||||
fallbackCompanyId: prevCompanyId.current,
|
||||
}),
|
||||
[companies, location.pathname],
|
||||
);
|
||||
|
||||
// Save current path for current company on every location change.
|
||||
// Uses prevCompanyId ref so we save under the correct company even
|
||||
// during the render where selectedCompanyId has already changed.
|
||||
const fullPath = location.pathname + location.search;
|
||||
useEffect(() => {
|
||||
const companyId = prevCompanyId.current;
|
||||
const companyId = rememberedPathOwnerCompanyId;
|
||||
const relativePath = toCompanyRelativePath(fullPath);
|
||||
if (companyId && isRememberableCompanyPath(relativePath)) {
|
||||
saveCompanyPath(companyId, relativePath);
|
||||
}
|
||||
}, [fullPath]);
|
||||
}, [fullPath, rememberedPathOwnerCompanyId]);
|
||||
|
||||
// Navigate to saved path when company changes
|
||||
useEffect(() => {
|
||||
@@ -63,9 +67,10 @@ export function useCompanyPageMemory() {
|
||||
) {
|
||||
if (selectionSource !== "route_sync" && selectedCompany) {
|
||||
const paths = getCompanyPaths();
|
||||
const savedPath = paths[selectedCompanyId];
|
||||
const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard";
|
||||
const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard";
|
||||
const targetPath = sanitizeRememberedPathForCompany({
|
||||
path: paths[selectedCompanyId],
|
||||
companyPrefix: selectedCompany.issuePrefix,
|
||||
});
|
||||
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +307,11 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.paperclip-edit-in-place-content {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -317,11 +322,11 @@
|
||||
|
||||
.paperclip-mdxeditor-content p {
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content p + p {
|
||||
margin-top: 0.6rem;
|
||||
margin-top: 1.1em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
||||
@@ -342,8 +347,8 @@
|
||||
|
||||
.paperclip-mdxeditor-content ul,
|
||||
.paperclip-mdxeditor-content ol {
|
||||
margin: 0.35rem 0;
|
||||
padding-left: 1.1rem;
|
||||
margin: 1.1em 0;
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content ul {
|
||||
@@ -356,32 +361,46 @@
|
||||
|
||||
.paperclip-mdxeditor-content li {
|
||||
display: list-item;
|
||||
margin: 0.15rem 0;
|
||||
line-height: 1.4;
|
||||
margin: 0.3em 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h1,
|
||||
.paperclip-mdxeditor-content h2,
|
||||
.paperclip-mdxeditor-content h3 {
|
||||
margin: 0.4rem 0 0.25rem;
|
||||
.paperclip-mdxeditor-content h1 {
|
||||
margin: 0 0 0.9em;
|
||||
font-size: 1.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h2 {
|
||||
margin: 0 0 0.85em;
|
||||
font-size: 1.35em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h3 {
|
||||
margin: 0 0 0.8em;
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content img {
|
||||
max-height: 18rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content blockquote {
|
||||
margin: 0.45rem 0;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 2px solid var(--border);
|
||||
margin: 1.2em 0;
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content code {
|
||||
|
||||
53
ui/src/lib/assignees.test.ts
Normal file
53
ui/src/lib/assignees.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
currentUserAssigneeOption,
|
||||
formatAssigneeUserLabel,
|
||||
parseAssigneeValue,
|
||||
} from "./assignees";
|
||||
|
||||
describe("assignee selection helpers", () => {
|
||||
it("encodes and parses agent assignees", () => {
|
||||
const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" });
|
||||
|
||||
expect(value).toBe("agent:agent-123");
|
||||
expect(parseAssigneeValue(value)).toEqual({
|
||||
assigneeAgentId: "agent-123",
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes and parses current-user assignees", () => {
|
||||
const [option] = currentUserAssigneeOption("local-board");
|
||||
|
||||
expect(option).toEqual({
|
||||
id: "user:local-board",
|
||||
label: "Me",
|
||||
searchText: "me board human local-board",
|
||||
});
|
||||
expect(parseAssigneeValue(option.id)).toEqual({
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty selection as no assignee", () => {
|
||||
expect(parseAssigneeValue("")).toEqual({
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps backward compatibility for raw agent ids in saved drafts", () => {
|
||||
expect(parseAssigneeValue("legacy-agent-id")).toEqual({
|
||||
assigneeAgentId: "legacy-agent-id",
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats current and board user labels consistently", () => {
|
||||
expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me");
|
||||
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
|
||||
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
|
||||
});
|
||||
});
|
||||
51
ui/src/lib/assignees.ts
Normal file
51
ui/src/lib/assignees.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface AssigneeSelection {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
export interface AssigneeOption {
|
||||
id: string;
|
||||
label: string;
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
|
||||
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
|
||||
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseAssigneeValue(value: string): AssigneeSelection {
|
||||
if (!value) {
|
||||
return { assigneeAgentId: null, assigneeUserId: null };
|
||||
}
|
||||
if (value.startsWith("agent:")) {
|
||||
const assigneeAgentId = value.slice("agent:".length);
|
||||
return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null };
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
const assigneeUserId = value.slice("user:".length);
|
||||
return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null };
|
||||
}
|
||||
// Backward compatibility for older drafts/defaults that stored a raw agent id.
|
||||
return { assigneeAgentId: value, assigneeUserId: null };
|
||||
}
|
||||
|
||||
export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] {
|
||||
if (!currentUserId) return [];
|
||||
return [{
|
||||
id: assigneeValueFromSelection({ assigneeUserId: currentUserId }),
|
||||
label: "Me",
|
||||
searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`,
|
||||
}];
|
||||
}
|
||||
|
||||
export function formatAssigneeUserLabel(
|
||||
userId: string | null | undefined,
|
||||
currentUserId: string | null | undefined,
|
||||
): string | null {
|
||||
if (!userId) return null;
|
||||
if (currentUserId && userId === currentUserId) return "Me";
|
||||
if (userId === "local-board") return "Board";
|
||||
return userId.slice(0, 5);
|
||||
}
|
||||
65
ui/src/lib/company-page-memory.ts
Normal file
65
ui/src/lib/company-page-memory.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
extractCompanyPrefixFromPath,
|
||||
normalizeCompanyPrefix,
|
||||
toCompanyRelativePath,
|
||||
} from "./company-routes";
|
||||
|
||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
|
||||
|
||||
export function isRememberableCompanyPath(path: string): boolean {
|
||||
const pathname = path.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return true;
|
||||
const [root] = segments;
|
||||
if (GLOBAL_SEGMENTS.has(root!)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findCompanyByPrefix<T extends { id: string; issuePrefix: string }>(params: {
|
||||
companies: T[];
|
||||
companyPrefix: string;
|
||||
}): T | null {
|
||||
const normalizedPrefix = normalizeCompanyPrefix(params.companyPrefix);
|
||||
return params.companies.find((company) => normalizeCompanyPrefix(company.issuePrefix) === normalizedPrefix) ?? null;
|
||||
}
|
||||
|
||||
export function getRememberedPathOwnerCompanyId<T extends { id: string; issuePrefix: string }>(params: {
|
||||
companies: T[];
|
||||
pathname: string;
|
||||
fallbackCompanyId: string | null;
|
||||
}): string | null {
|
||||
const routeCompanyPrefix = extractCompanyPrefixFromPath(params.pathname);
|
||||
if (!routeCompanyPrefix) {
|
||||
return params.fallbackCompanyId;
|
||||
}
|
||||
|
||||
return findCompanyByPrefix({
|
||||
companies: params.companies,
|
||||
companyPrefix: routeCompanyPrefix,
|
||||
})?.id ?? null;
|
||||
}
|
||||
|
||||
export function sanitizeRememberedPathForCompany(params: {
|
||||
path: string | null | undefined;
|
||||
companyPrefix: string;
|
||||
}): string {
|
||||
const relativePath = params.path ? toCompanyRelativePath(params.path) : "/dashboard";
|
||||
if (!isRememberableCompanyPath(relativePath)) {
|
||||
return "/dashboard";
|
||||
}
|
||||
|
||||
const pathname = relativePath.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const [root, entityId] = segments;
|
||||
if (root === "issues" && entityId) {
|
||||
const identifierMatch = /^([A-Za-z]+)-\d+$/.exec(entityId);
|
||||
if (
|
||||
identifierMatch &&
|
||||
normalizeCompanyPrefix(identifierMatch[1] ?? "") !== normalizeCompanyPrefix(params.companyPrefix)
|
||||
) {
|
||||
return "/dashboard";
|
||||
}
|
||||
}
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
34
ui/src/lib/company-selection.test.ts
Normal file
34
ui/src/lib/company-selection.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldSyncCompanySelectionFromRoute } from "./company-selection";
|
||||
|
||||
describe("shouldSyncCompanySelectionFromRoute", () => {
|
||||
it("does not resync when selection already matches the route", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "route_sync",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "pap",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("defers route sync while a manual company switch is in flight", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "manual",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "ret",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("syncs back to the route company for non-manual mismatches", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "route_sync",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "ret",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
18
ui/src/lib/company-selection.ts
Normal file
18
ui/src/lib/company-selection.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
|
||||
|
||||
export function shouldSyncCompanySelectionFromRoute(params: {
|
||||
selectionSource: CompanySelectionSource;
|
||||
selectedCompanyId: string | null;
|
||||
routeCompanyId: string;
|
||||
}): boolean {
|
||||
const { selectionSource, selectedCompanyId, routeCompanyId } = params;
|
||||
|
||||
if (selectedCompanyId === routeCompanyId) return false;
|
||||
|
||||
// Let manual company switches finish their remembered-path navigation first.
|
||||
if (selectionSource === "manual" && selectedCompanyId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
|
||||
65
ui/src/lib/worktree-branding.ts
Normal file
65
ui/src/lib/worktree-branding.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export type WorktreeUiBranding = {
|
||||
enabled: true;
|
||||
name: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
function readMetaContent(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const element = document.querySelector(`meta[name="${name}"]`);
|
||||
const content = element?.getAttribute("content")?.trim();
|
||||
return content ? content : null;
|
||||
}
|
||||
|
||||
function normalizeHexColor(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const hex = value.startsWith("#") ? value.slice(1) : value;
|
||||
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
||||
return `#${hex.split("").map((char) => `${char}${char}`).join("").toLowerCase()}`;
|
||||
}
|
||||
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
return `#${hex.toLowerCase()}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hexToRgb(color: string): { r: number; g: number; b: number } {
|
||||
const normalized = normalizeHexColor(color) ?? "#000000";
|
||||
return {
|
||||
r: Number.parseInt(normalized.slice(1, 3), 16),
|
||||
g: Number.parseInt(normalized.slice(3, 5), 16),
|
||||
b: Number.parseInt(normalized.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function relativeLuminanceChannel(value: number): number {
|
||||
const normalized = value / 255;
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function pickReadableTextColor(background: string): string {
|
||||
const { r, g, b } = hexToRgb(background);
|
||||
const luminance =
|
||||
(0.2126 * relativeLuminanceChannel(r)) +
|
||||
(0.7152 * relativeLuminanceChannel(g)) +
|
||||
(0.0722 * relativeLuminanceChannel(b));
|
||||
const whiteContrast = 1.05 / (luminance + 0.05);
|
||||
const blackContrast = (luminance + 0.05) / 0.05;
|
||||
return whiteContrast >= blackContrast ? "#f8fafc" : "#111827";
|
||||
}
|
||||
|
||||
export function getWorktreeUiBranding(): WorktreeUiBranding | null {
|
||||
if (readMetaContent("paperclip-worktree-enabled") !== "true") return null;
|
||||
|
||||
const name = readMetaContent("paperclip-worktree-name");
|
||||
const color = normalizeHexColor(readMetaContent("paperclip-worktree-color"));
|
||||
if (!name || !color) return null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
name,
|
||||
color,
|
||||
textColor: normalizeHexColor(readMetaContent("paperclip-worktree-text-color")) ?? pickReadableTextColor(color),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
@@ -17,6 +17,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
@@ -67,6 +68,9 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
"issue.document_created": "created a document",
|
||||
"issue.document_updated": "updated a document",
|
||||
"issue.document_deleted": "deleted a document",
|
||||
"issue.deleted": "deleted the issue",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
@@ -104,6 +108,36 @@ function truncate(text: string, max: number): string {
|
||||
return text.slice(0, max - 1) + "\u2026";
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: File) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown") ||
|
||||
file.type === "text/markdown"
|
||||
);
|
||||
}
|
||||
|
||||
function fileBaseName(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
function slugifyDocumentKey(input: string) {
|
||||
const slug = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "document";
|
||||
}
|
||||
|
||||
function titleizeFilename(input: string) {
|
||||
return input
|
||||
.split(/[-_ ]+/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
@@ -137,6 +171,14 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
||||
|
||||
if (parts.length > 0) return parts.join(", ");
|
||||
}
|
||||
if (
|
||||
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
|
||||
details
|
||||
) {
|
||||
const key = typeof details.key === "string" ? details.key : "document";
|
||||
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
||||
return `${ACTION_LABELS[action] ?? action} ${key}${title}`;
|
||||
}
|
||||
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
@@ -193,6 +235,7 @@ export function IssueDetail() {
|
||||
const [newWorkProductStatus, setNewWorkProductStatus] = useState<IssueWorkProduct["status"]>("active");
|
||||
const [newWorkProductReviewState, setNewWorkProductReviewState] = useState<IssueWorkProduct["reviewState"]>("none");
|
||||
const [newWorkProductSummary, setNewWorkProductSummary] = useState("");
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -337,8 +380,7 @@ export function IssueDetail() {
|
||||
options.push({ id: `agent:${agent.id}`, label: agent.name });
|
||||
}
|
||||
if (currentUserId) {
|
||||
const label = currentUserId === "local-board" ? "Board" : "Me (Board)";
|
||||
options.push({ id: `user:${currentUserId}`, label });
|
||||
options.push({ id: `user:${currentUserId}`, label: "Me" });
|
||||
}
|
||||
return options;
|
||||
}, [agents, currentUserId]);
|
||||
@@ -418,6 +460,7 @@ export function IssueDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(issueId!) });
|
||||
@@ -493,6 +536,30 @@ export function IssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const importMarkdownDocument = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const baseName = fileBaseName(file.name);
|
||||
const key = slugifyDocumentKey(baseName);
|
||||
const existing = (issue?.documentSummaries ?? []).find((doc) => doc.key === key) ?? null;
|
||||
const body = await file.text();
|
||||
const inferredTitle = titleizeFilename(baseName);
|
||||
const nextTitle = existing?.title ?? inferredTitle ?? null;
|
||||
return issuesApi.upsertDocument(issueId!, key, {
|
||||
title: key === "plan" ? null : nextTitle,
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: existing?.latestRevisionId ?? null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setAttachmentError(null);
|
||||
invalidateIssue();
|
||||
},
|
||||
onError: (err) => {
|
||||
setAttachmentError(err instanceof Error ? err.message : "Document import failed");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAttachment = useMutation({
|
||||
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
|
||||
onSuccess: () => {
|
||||
@@ -585,15 +652,62 @@ export function IssueDetail() {
|
||||
workProducts.length > 0;
|
||||
|
||||
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
const files = evt.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
if (isMarkdownFile(file)) {
|
||||
await importMarkdownDocument.mutateAsync(file);
|
||||
} else {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachmentDrop = async (evt: DragEvent<HTMLDivElement>) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(false);
|
||||
const files = evt.dataTransfer.files;
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
if (isMarkdownFile(file)) {
|
||||
await importMarkdownDocument.mutateAsync(file);
|
||||
} else {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||
const attachmentList = attachments ?? [];
|
||||
const hasAttachments = attachmentList.length > 0;
|
||||
const attachmentUploadButton = (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
|
||||
className="hidden"
|
||||
onChange={handleFilePicked}
|
||||
multiple
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
|
||||
className={cn(
|
||||
"shadow-none",
|
||||
attachmentDragActive && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
@@ -734,14 +848,14 @@ export function IssueDetail() {
|
||||
|
||||
<InlineEditor
|
||||
value={issue.title}
|
||||
onSave={(title) => updateIssue.mutate({ title })}
|
||||
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||
as="h2"
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
onSave={(description) => updateIssue.mutate({ description })}
|
||||
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||
as="p"
|
||||
className="text-[15px] leading-7 text-foreground"
|
||||
placeholder="Add a description..."
|
||||
@@ -754,77 +868,86 @@ export function IssueDetail() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<IssueDocumentsSection
|
||||
issue={issue}
|
||||
canDeleteDocuments={Boolean(session?.user?.id)}
|
||||
mentions={mentionOptions}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
|
||||
/>
|
||||
|
||||
{hasAttachments ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3 rounded-lg transition-colors",
|
||||
)}
|
||||
onDragEnter={(evt) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(true);
|
||||
}}
|
||||
onDragOver={(evt) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(true);
|
||||
}}
|
||||
onDragLeave={(evt) => {
|
||||
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
||||
setAttachmentDragActive(false);
|
||||
}}
|
||||
onDrop={(evt) => void handleAttachmentDrop(evt)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleFilePicked}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadAttachment.isPending}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
|
||||
</Button>
|
||||
</div>
|
||||
{attachmentUploadButton}
|
||||
</div>
|
||||
|
||||
{attachmentError && (
|
||||
<p className="text-xs text-destructive">{attachmentError}</p>
|
||||
)}
|
||||
|
||||
{(!attachments || attachments.length === 0) ? (
|
||||
<p className="text-xs text-muted-foreground">No attachments yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={attachment.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs hover:underline truncate"
|
||||
title={attachment.originalFilename ?? attachment.id}
|
||||
>
|
||||
{attachment.originalFilename ?? attachment.id}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||
disabled={deleteAttachment.isPending}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{isImageAttachment(attachment) && (
|
||||
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{attachmentList.map((attachment) => (
|
||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={attachment.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs hover:underline truncate"
|
||||
title={attachment.originalFilename ?? attachment.id}
|
||||
>
|
||||
{attachment.originalFilename ?? attachment.id}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||
disabled={deleteAttachment.isPending}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{isImageAttachment(attachment) && (
|
||||
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user