From 4a368f54d5de458081a352850d99d95fd0200ed1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 14:37:30 -0500 Subject: [PATCH 1/6] Delay onboarding starter task creation until launch Co-Authored-By: Paperclip --- ui/src/components/OnboardingWizard.tsx | 64 ++++++++++++++------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 5d166929..88e16d09 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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() {

Ready to launch

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

@@ -1291,7 +1295,7 @@ export function OnboardingWizard() { ) : ( )} - {loading ? "Opening..." : "Open Issue"} + {loading ? "Creating..." : "Create & Open Issue"} )} From 2b9de934e3b31656c535d4dbe3092c29c87b6c74 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:04:28 -0500 Subject: [PATCH 2/6] Fix manual company switch route sync Co-Authored-By: Paperclip --- ui/src/components/Layout.tsx | 11 ++++++++- ui/src/context/CompanyContext.tsx | 3 +-- ui/src/lib/company-selection.test.ts | 34 ++++++++++++++++++++++++++++ ui/src/lib/company-selection.ts | 18 +++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 ui/src/lib/company-selection.test.ts create mode 100644 ui/src/lib/company-selection.ts diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a90efa9a..e484b265 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -22,6 +22,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"; @@ -36,6 +37,7 @@ export function Layout() { loading: companiesLoading, selectedCompany, selectedCompanyId, + selectionSource, setSelectedCompanyId, } = useCompany(); const { theme, toggleTheme } = useTheme(); @@ -88,7 +90,13 @@ export function Layout() { return; } - if (selectedCompanyId !== matchedCompany.id) { + if ( + shouldSyncCompanySelectionFromRoute({ + selectionSource, + selectedCompanyId, + routeCompanyId: matchedCompany.id, + }) + ) { setSelectedCompanyId(matchedCompany.id, { source: "route_sync" }); } }, [ @@ -99,6 +107,7 @@ export function Layout() { location.pathname, location.search, navigate, + selectionSource, selectedCompanyId, setSelectedCompanyId, ]); diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index eafc7f55..fb074f33 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -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 { diff --git a/ui/src/lib/company-selection.test.ts b/ui/src/lib/company-selection.test.ts new file mode 100644 index 00000000..a8533a4b --- /dev/null +++ b/ui/src/lib/company-selection.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/company-selection.ts b/ui/src/lib/company-selection.ts new file mode 100644 index 00000000..ce02cb4d --- /dev/null +++ b/ui/src/lib/company-selection.ts @@ -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; +} From 6365e03731ba8f6c8d3a6d9f831112248e8f1bb6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:11:37 -0500 Subject: [PATCH 3/6] feat: skip pre-filled assignee/project fields when tabbing in new issue dialog When creating a new issue with a pre-filled assignee or project (e.g. from a project page), Tab from the title field now skips over fields that already have values, going directly to the next empty field or description. Co-Authored-By: Paperclip --- ui/src/components/NewIssueDialog.tsx | 96 ++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 01f210ed..c017306c 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -10,6 +10,11 @@ import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { + assigneeValueFromSelection, + currentUserAssigneeOption, + parseAssigneeValue, +} from "../lib/assignees"; import { Dialog, DialogContent, @@ -63,7 +68,8 @@ interface IssueDraft { description: string; status: string; priority: string; - assigneeId: string; + assigneeValue: string; + assigneeId?: string; projectId: string; assigneeModelOverride: string; assigneeThinkingEffort: string; @@ -173,7 +179,7 @@ export function NewIssueDialog() { 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 [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [assigneeModelOverride, setAssigneeModelOverride] = useState(""); @@ -220,7 +226,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), ); @@ -295,7 +305,7 @@ export function NewIssueDialog() { description, status, priority, - assigneeId, + assigneeValue, projectId, assigneeModelOverride, assigneeThinkingEffort, @@ -307,7 +317,7 @@ export function NewIssueDialog() { description, status, priority, - assigneeId, + assigneeValue, projectId, assigneeModelOverride, assigneeThinkingEffort, @@ -330,7 +340,7 @@ export function NewIssueDialog() { setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); setProjectId(newIssueDefaults.projectId ?? ""); - setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -340,7 +350,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(newIssueDefaults.projectId ?? draft.projectId); setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); @@ -350,7 +364,7 @@ export function NewIssueDialog() { setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); setProjectId(newIssueDefaults.projectId ?? ""); - setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -390,7 +404,7 @@ export function NewIssueDialog() { setDescription(""); setStatus("todo"); setPriority(""); - setAssigneeId(""); + setAssigneeValue(""); setProjectId(""); setAssigneeOptionsOpen(false); setAssigneeModelOverride(""); @@ -406,7 +420,7 @@ export function NewIssueDialog() { function handleCompanyChange(companyId: string) { if (companyId === effectiveCompanyId) return; setDialogCompanyId(companyId); - setAssigneeId(""); + setAssigneeValue(""); setProjectId(""); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); @@ -443,7 +457,8 @@ export function NewIssueDialog() { description: description.trim() || undefined, status, priority: priority || "medium", - ...(assigneeId ? { assigneeAgentId: assigneeId } : {}), + ...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}), + ...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}), ...(projectId ? { projectId } : {}), ...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), @@ -475,7 +490,9 @@ export function NewIssueDialog() { const hasDraft = title.trim().length > 0 || description.trim().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 currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI ? currentProject?.executionWorkspacePolicy ?? null @@ -497,16 +514,18 @@ export function NewIssueDialog() { : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]); const assigneeOptions = useMemo( - () => - 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( () => @@ -710,7 +729,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 @@ -723,33 +751,49 @@ export function NewIssueDialog() { For { 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 ? ( - <> - + option ? ( + currentAssignee ? ( + <> + + {option.label} + + ) : ( {option.label} - + ) ) : ( Assignee ) } renderOption={(option) => { if (!option.id) return {option.label}; - 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 ( <> - + {assignee ? : null} {option.label} ); From 32ab4f8e47f55a983f056d6d438ea134ac22237a Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:12:38 -0500 Subject: [PATCH 4/6] Add me and unassigned assignee options Co-Authored-By: Paperclip --- ui/src/components/IssueProperties.tsx | 29 +++--- ui/src/components/IssuesList.tsx | 123 ++++++++++++++++++++------ ui/src/context/DialogContext.tsx | 1 + ui/src/lib/assignees.test.ts | 53 +++++++++++ ui/src/lib/assignees.ts | 51 +++++++++++ ui/src/pages/IssueDetail.tsx | 3 +- 6 files changed, 219 insertions(+), 41 deletions(-) create mode 100644 ui/src/lib/assignees.test.ts create mode 100644 ui/src/lib/assignees.ts diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ca8e1bd4..cf4b6a43 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -10,6 +10,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"; @@ -206,14 +207,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); @@ -349,7 +343,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > No assignee - {issue.createdByUserId && ( + {currentUserId && ( + + )} + {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( )} {sortedAgents diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6899bd5c..442f8ae4 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -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 = {}; @@ -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({ {/* Assignee */} - {agents && agents.length > 0 && ( -
- Assignee -
- {agents.map((agent) => ( - - ))} -
+
+ Assignee +
+ + {currentUserId && ( + + )} + {(agents ?? []).map((agent) => ( + + ))}
- )} +
{labels && labels.length > 0 && (
@@ -683,6 +725,13 @@ export function IssuesList({ > {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? ( + ) : issue.assigneeUserId ? ( + + + + + {formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"} + ) : ( @@ -701,7 +750,7 @@ export function IssuesList({ > setAssigneeSearch(e.target.value)} autoFocus @@ -710,16 +759,32 @@ export function IssuesList({ + {currentUserId && ( + + )} {(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); }} > diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index ef7b12b8..904ceb88 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,7 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + assigneeUserId?: string; title?: string; description?: string; } diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts new file mode 100644 index 00000000..1ce22ef7 --- /dev/null +++ b/ui/src/lib/assignees.test.ts @@ -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-"); + }); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts new file mode 100644 index 00000000..274bcd40 --- /dev/null +++ b/ui/src/lib/assignees.ts @@ -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): 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); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index bb152e17..9a43f26a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -304,8 +304,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]); From 41eb8e51e33610d3b7095abfa2b5adf2c1ff35af Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 09:58:26 -0500 Subject: [PATCH 5/6] Fix company switch remembered routes Co-Authored-By: Paperclip --- ui/src/hooks/useCompanyPageMemory.test.ts | 71 +++++++++++++++++++++++ ui/src/hooks/useCompanyPageMemory.ts | 39 +++++++------ ui/src/lib/company-page-memory.ts | 65 +++++++++++++++++++++ 3 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 ui/src/hooks/useCompanyPageMemory.test.ts create mode 100644 ui/src/lib/company-page-memory.ts diff --git a/ui/src/hooks/useCompanyPageMemory.test.ts b/ui/src/hooks/useCompanyPageMemory.test.ts new file mode 100644 index 00000000..a64c60b8 --- /dev/null +++ b/ui/src/hooks/useCompanyPageMemory.test.ts @@ -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"); + }); +}); diff --git a/ui/src/hooks/useCompanyPageMemory.ts b/ui/src/hooks/useCompanyPageMemory.ts index d427e587..5206df11 100644 --- a/ui/src/hooks/useCompanyPageMemory.ts +++ b/ui/src/hooks/useCompanyPageMemory.ts @@ -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 { 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(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 }); } } diff --git a/ui/src/lib/company-page-memory.ts b/ui/src/lib/company-page-memory.ts new file mode 100644 index 00000000..df549b68 --- /dev/null +++ b/ui/src/lib/company-page-memory.ts @@ -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(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(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; +} From cce9941464b4107b66ba02918f62689a54954a8d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 11:12:43 -0500 Subject: [PATCH 6/6] Add worktree UI branding --- cli/src/__tests__/worktree.test.ts | 17 +- cli/src/commands/worktree-lib.ts | 58 ++++- cli/src/commands/worktree.ts | 12 +- doc/DEVELOPING.md | 8 +- server/src/__tests__/ui-branding.test.ts | 66 +++++- server/src/ui-branding.ts | 208 +++++++++++++++-- ui/index.html | 2 + ui/src/components/Layout.tsx | 274 ++++++++++++----------- ui/src/components/WorktreeBanner.tsx | 25 +++ ui/src/lib/worktree-branding.ts | 65 ++++++ 10 files changed, 566 insertions(+), 169 deletions(-) create mode 100644 ui/src/components/WorktreeBanner.tsx create mode 100644 ui/src/lib/worktree-branding.ts diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 115d03b3..fe325cd2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -16,6 +16,7 @@ import { buildWorktreeConfig, buildWorktreeEnvEntries, formatShellExports, + generateWorktreeColor, resolveWorktreeSeedPlan, resolveWorktreeLocalPaths, rewriteLocalUrlPort, @@ -181,13 +182,22 @@ describe("worktree helpers", () => { path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"), ); - const env = buildWorktreeEnvEntries(paths); + const env = buildWorktreeEnvEntries(paths, { + name: "feature-worktree-support", + color: "#3abf7a", + }); expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support"); + expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a"); expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); }); + it("generates vivid worktree colors as hex", () => { + expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/); + }); + it("uses minimal seed mode to keep app state but drop heavy runtime history", () => { const minimal = resolveWorktreeSeedPlan("minimal"); const full = resolveWorktreeSeedPlan("full"); @@ -280,7 +290,10 @@ describe("worktree helpers", () => { }); const envPath = path.join(repoRoot, ".paperclip", ".env"); - expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); + const envContents = fs.readFileSync(envPath, "utf8"); + expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); + expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo"); + expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=#[0-9a-f]{6}/); } finally { process.chdir(originalCwd); if (originalJwtSecret === undefined) { diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 4a0a3aeb..5249acc2 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -1,3 +1,4 @@ +import { randomInt } from "node:crypto"; import path from "node:path"; import type { PaperclipConfig } from "../config/schema.js"; import { expandHomePrefix } from "../config/home.js"; @@ -44,6 +45,11 @@ export type WorktreeLocalPaths = { storageDir: string; }; +export type WorktreeUiBranding = { + name: string; + color: string; +}; + export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { return (WORKTREE_SEED_MODES as readonly string[]).includes(value); } @@ -87,6 +93,51 @@ export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string) return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd)); } +function hslComponentToHex(n: number): string { + return Math.round(Math.max(0, Math.min(255, n))) + .toString(16) + .padStart(2, "0"); +} + +function hslToHex(hue: number, saturation: number, lightness: number): string { + const s = Math.max(0, Math.min(100, saturation)) / 100; + const l = Math.max(0, Math.min(100, lightness)) / 100; + const c = (1 - Math.abs((2 * l) - 1)) * s; + const h = ((hue % 360) + 360) % 360; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - (c / 2); + + let r = 0; + let g = 0; + let b = 0; + + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`; +} + +export function generateWorktreeColor(): string { + return hslToHex(randomInt(0, 360), 68, 56); +} + export function resolveWorktreeLocalPaths(opts: { cwd: string; homeDir?: string; @@ -196,13 +247,18 @@ export function buildWorktreeConfig(input: { }; } -export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record { +export function buildWorktreeEnvEntries( + paths: WorktreeLocalPaths, + branding?: WorktreeUiBranding, +): Record { return { PAPERCLIP_HOME: paths.homeDir, PAPERCLIP_INSTANCE_ID: paths.instanceId, PAPERCLIP_CONFIG: paths.configPath, PAPERCLIP_CONTEXT: paths.contextPath, PAPERCLIP_IN_WORKTREE: "true", + ...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}), + ...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}), }; } diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 582bb5dd..fca320b9 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -39,6 +39,7 @@ import { buildWorktreeEnvEntries, DEFAULT_WORKTREE_HOME, formatShellExports, + generateWorktreeColor, isWorktreeSeedMode, resolveSuggestedWorktreeName, resolveWorktreeSeedPlan, @@ -623,7 +624,7 @@ async function seedWorktreeDatabase(input: { async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const cwd = process.cwd(); - const name = resolveSuggestedWorktreeName( + const worktreeName = resolveSuggestedWorktreeName( cwd, opts.name ?? detectGitBranchName(cwd) ?? undefined, ); @@ -631,12 +632,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { if (!isWorktreeSeedMode(seedMode)) { throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); } - const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); + const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName); const paths = resolveWorktreeLocalPaths({ cwd, homeDir: resolveWorktreeHome(opts.home), instanceId, }); + const branding = { + name: worktreeName, + color: generateWorktreeColor(), + }; const sourceConfigPath = resolveSourceConfigPath(opts); const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; @@ -669,7 +674,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET); mergePaperclipEnvEntries( { - ...buildWorktreeEnvEntries(paths), + ...buildWorktreeEnvEntries(paths, branding), ...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), }, paths.envPath, @@ -710,6 +715,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { p.log.message(pc.dim(`Repo env: ${paths.envPath}`)); p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`)); p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); + p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`)); p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); if (copiedGitHooks?.copied) { p.log.message( diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 1ca1409b..4b379dcb 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -152,7 +152,13 @@ Seed modes: After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. -That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon. +That repo-local env also sets: + +- `PAPERCLIP_IN_WORKTREE=true` +- `PAPERCLIP_WORKTREE_NAME=` +- `PAPERCLIP_WORKTREE_COLOR=` + +The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon. Print shell exports explicitly when needed: diff --git a/server/src/__tests__/ui-branding.test.ts b/server/src/__tests__/ui-branding.test.ts index 858823bb..c649d5c9 100644 --- a/server/src/__tests__/ui-branding.test.ts +++ b/server/src/__tests__/ui-branding.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; -import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js"; +import { + applyUiBranding, + getWorktreeUiBranding, + isWorktreeUiBrandingEnabled, + renderFaviconLinks, + renderRuntimeBrandingMeta, +} from "../ui-branding.js"; const TEMPLATE = ` + + @@ -18,21 +26,57 @@ describe("ui branding", () => { expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false); }); - it("renders the worktree favicon asset set when enabled", () => { - const links = renderFaviconLinks(true); - expect(links).toContain("/worktree-favicon.ico"); - expect(links).toContain("/worktree-favicon.svg"); - expect(links).toContain("/worktree-favicon-32x32.png"); - expect(links).toContain("/worktree-favicon-16x16.png"); + it("resolves name, color, and text color for worktree branding", () => { + const branding = getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }); + + expect(branding.enabled).toBe(true); + expect(branding.name).toBe("paperclip-pr-432"); + expect(branding.color).toBe("#4f86f7"); + expect(branding.textColor).toMatch(/^#[0-9a-f]{6}$/); + expect(branding.faviconHref).toContain("data:image/svg+xml,"); }); - it("rewrites the favicon block for worktree instances only", () => { - const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" }); - expect(branded).toContain("/worktree-favicon.svg"); + it("renders a dynamic worktree favicon when enabled", () => { + const links = renderFaviconLinks( + getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }), + ); + expect(links).toContain("data:image/svg+xml,"); + expect(links).toContain('rel="shortcut icon"'); + }); + + it("renders runtime branding metadata for the ui", () => { + const meta = renderRuntimeBrandingMeta( + getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }), + ); + expect(meta).toContain('name="paperclip-worktree-name"'); + expect(meta).toContain('content="paperclip-pr-432"'); + expect(meta).toContain('name="paperclip-worktree-color"'); + }); + + it("rewrites the favicon and runtime branding blocks for worktree instances only", () => { + const branded = applyUiBranding(TEMPLATE, { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }); + expect(branded).toContain("data:image/svg+xml,"); + expect(branded).toContain('name="paperclip-worktree-name"'); expect(branded).not.toContain('href="/favicon.svg"'); const defaultHtml = applyUiBranding(TEMPLATE, {}); expect(defaultHtml).toContain('href="/favicon.svg"'); - expect(defaultHtml).not.toContain("/worktree-favicon.svg"); + expect(defaultHtml).not.toContain('name="paperclip-worktree-name"'); }); }); diff --git a/server/src/ui-branding.ts b/server/src/ui-branding.ts index bb6f3a33..8195c91e 100644 --- a/server/src/ui-branding.ts +++ b/server/src/ui-branding.ts @@ -1,5 +1,7 @@ const FAVICON_BLOCK_START = ""; const FAVICON_BLOCK_END = ""; +const RUNTIME_BRANDING_BLOCK_START = ""; +const RUNTIME_BRANDING_BLOCK_END = ""; const DEFAULT_FAVICON_LINKS = [ '', @@ -8,12 +10,13 @@ const DEFAULT_FAVICON_LINKS = [ '', ].join("\n"); -const WORKTREE_FAVICON_LINKS = [ - '', - '', - '', - '', -].join("\n"); +export type WorktreeUiBranding = { + enabled: boolean; + name: string | null; + color: string | null; + textColor: string | null; + faviconHref: string | null; +}; function isTruthyEnvValue(value: string | undefined): boolean { if (!value) return false; @@ -21,21 +24,194 @@ function isTruthyEnvValue(value: string | undefined): boolean { return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } +function nonEmpty(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function normalizeHexColor(value: string | undefined): string | null { + const raw = nonEmpty(value); + if (!raw) return null; + const hex = raw.startsWith("#") ? raw.slice(1) : raw; + 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 hslComponentToHex(n: number): string { + return Math.round(Math.max(0, Math.min(255, n))) + .toString(16) + .padStart(2, "0"); +} + +function hslToHex(hue: number, saturation: number, lightness: number): string { + const s = Math.max(0, Math.min(100, saturation)) / 100; + const l = Math.max(0, Math.min(100, lightness)) / 100; + const c = (1 - Math.abs((2 * l) - 1)) * s; + const h = ((hue % 360) + 360) % 360; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - (c / 2); + + let r = 0; + let g = 0; + let b = 0; + + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`; +} + +function deriveColorFromSeed(seed: string): string { + let hash = 0; + for (const char of seed) { + hash = ((hash * 33) + char.charCodeAt(0)) >>> 0; + } + return hslToHex(hash % 360, 68, 56); +} + +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 relativeLuminance(color: string): number { + const { r, g, b } = hexToRgb(color); + return ( + (0.2126 * relativeLuminanceChannel(r)) + + (0.7152 * relativeLuminanceChannel(g)) + + (0.0722 * relativeLuminanceChannel(b)) + ); +} + +function pickReadableTextColor(background: string): string { + const backgroundLuminance = relativeLuminance(background); + const whiteContrast = 1.05 / (backgroundLuminance + 0.05); + const blackContrast = (backgroundLuminance + 0.05) / 0.05; + return whiteContrast >= blackContrast ? "#f8fafc" : "#111827"; +} + +function escapeHtmlAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function createFaviconDataUrl(background: string, foreground: string): string { + const svg = [ + '', + ``, + ``, + "", + ].join(""); + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE); } -export function renderFaviconLinks(worktree: boolean): string { - return worktree ? WORKTREE_FAVICON_LINKS : DEFAULT_FAVICON_LINKS; +export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): WorktreeUiBranding { + if (!isWorktreeUiBrandingEnabled(env)) { + return { + enabled: false, + name: null, + color: null, + textColor: null, + faviconHref: null, + }; + } + + const name = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? "worktree"; + const color = normalizeHexColor(env.PAPERCLIP_WORKTREE_COLOR) ?? deriveColorFromSeed(name); + const textColor = pickReadableTextColor(color); + + return { + enabled: true, + name, + color, + textColor, + faviconHref: createFaviconDataUrl(color, textColor), + }; +} + +export function renderFaviconLinks(branding: WorktreeUiBranding): string { + if (!branding.enabled || !branding.faviconHref) return DEFAULT_FAVICON_LINKS; + + const href = escapeHtmlAttribute(branding.faviconHref); + return [ + ``, + ``, + ].join("\n"); +} + +export function renderRuntimeBrandingMeta(branding: WorktreeUiBranding): string { + if (!branding.enabled || !branding.name || !branding.color || !branding.textColor) return ""; + + return [ + '', + ``, + ``, + ``, + ].join("\n"); +} + +function replaceMarkedBlock(html: string, startMarker: string, endMarker: string, content: string): string { + const start = html.indexOf(startMarker); + const end = html.indexOf(endMarker); + if (start === -1 || end === -1 || end < start) return html; + + const before = html.slice(0, start + startMarker.length); + const after = html.slice(end); + const indentedContent = content + ? `\n${content + .split("\n") + .map((line) => ` ${line}`) + .join("\n")}\n ` + : "\n "; + return `${before}${indentedContent}${after}`; } export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string { - const start = html.indexOf(FAVICON_BLOCK_START); - const end = html.indexOf(FAVICON_BLOCK_END); - if (start === -1 || end === -1 || end < start) return html; - - const before = html.slice(0, start + FAVICON_BLOCK_START.length); - const after = html.slice(end); - const links = renderFaviconLinks(isWorktreeUiBrandingEnabled(env)); - return `${before}\n${links}\n ${after}`; + const branding = getWorktreeUiBranding(env); + const withFavicon = replaceMarkedBlock(html, FAVICON_BLOCK_START, FAVICON_BLOCK_END, renderFaviconLinks(branding)); + return replaceMarkedBlock( + withFavicon, + RUNTIME_BRANDING_BLOCK_START, + RUNTIME_BRANDING_BLOCK_END, + renderRuntimeBrandingMeta(branding), + ); } diff --git a/ui/index.html b/ui/index.html index 7994c0d2..1bb9152e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,8 @@ Paperclip + + diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index e484b265..43094b51 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -14,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"; @@ -223,7 +224,7 @@ export function Layout() {
Skip to Main Content - {/* Mobile backdrop */} - {isMobile && sidebarOpen && ( - - -
-
-
- ) : ( -
-
- -
- {isInstanceSettingsRoute ? : } -
-
-
-
- - - Documentation - - - -
-
-
- )} - - {/* Main content */} -
-
- -
-
-
- {hasUnknownCompanyPrefix ? ( - - ) : ( - +
+ + {isInstanceSettingsRoute ? : } +
+
+
+ + + Documentation + + + +
+
+
+ ) : ( +
+
+ +
+ {isInstanceSettingsRoute ? : } +
+
+
+
+ + + Documentation + + + +
+
+
+ )} + + {/* Main content */} +
+
- + > + +
+
+
+ {hasUnknownCompanyPrefix ? ( + + ) : ( + + )} +
+ +
{isMobile && } diff --git a/ui/src/components/WorktreeBanner.tsx b/ui/src/components/WorktreeBanner.tsx new file mode 100644 index 00000000..6808b2da --- /dev/null +++ b/ui/src/components/WorktreeBanner.tsx @@ -0,0 +1,25 @@ +import { getWorktreeUiBranding } from "../lib/worktree-branding"; + +export function WorktreeBanner() { + const branding = getWorktreeUiBranding(); + if (!branding) return null; + + return ( +
+
+ Worktree +
+
+ ); +} diff --git a/ui/src/lib/worktree-branding.ts b/ui/src/lib/worktree-branding.ts new file mode 100644 index 00000000..6f6d8dc4 --- /dev/null +++ b/ui/src/lib/worktree-branding.ts @@ -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), + }; +}