From 4a368f54d5de458081a352850d99d95fd0200ed1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 14:37:30 -0500 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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; +}