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:
Dotta
2026-03-14 12:24:40 -05:00
136 changed files with 17867 additions and 1511 deletions

View File

@@ -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",

View File

@@ -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} />
)}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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

View File

@@ -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" />

View File

@@ -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} />}

View File

@@ -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 = `![${name}](${asset.contentPath})`;
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) */}

View File

@@ -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>

View 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>
);
}

View File

@@ -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.",