Merge pull request #802 from paperclipai/fix/ui-routing-and-assignee-polish

fix(ui): polish company switching, issue tab order, and assignee filters
This commit is contained in:
Dotta
2026-03-13 10:11:09 -05:00
committed by GitHub
15 changed files with 544 additions and 117 deletions

View File

@@ -10,6 +10,7 @@ import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon"; import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
@@ -206,14 +207,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const assignee = issue.assigneeAgentId const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId) ? agents?.find((a) => a.id === issue.assigneeAgentId)
: null; : null;
const userLabel = (userId: string | null | undefined) => const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
userId
? userId === "local-board"
? "Board"
: currentUserId && userId === currentUserId
? "Me"
: userId.slice(0, 5)
: null;
const assigneeUserLabel = userLabel(issue.assigneeUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId); const creatorUserLabel = userLabel(issue.createdByUserId);
@@ -349,7 +343,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
> >
No assignee No assignee
</button> </button>
{issue.createdByUserId && ( {currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button <button
className={cn( className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50", "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
@@ -361,7 +370,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
}} }}
> >
<User className="h-3 w-3 shrink-0 text-muted-foreground" /> <User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"} {creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button> </button>
)} )}
{sortedAgents {sortedAgents

View File

@@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { groupBy } from "../lib/groupBy"; import { groupBy } from "../lib/groupBy";
import { formatDate, cn } from "../lib/utils"; import { formatDate, cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo"; 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]; 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; let result = issues;
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); 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.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))); if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
return result; return result;
} }
@@ -165,6 +176,11 @@ export function IssuesList({
}: IssuesListProps) { }: IssuesListProps) {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog(); 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. // Scope the storage key per company so folding/view state is independent across companies.
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
@@ -224,9 +240,9 @@ export function IssuesList({
const filtered = useMemo(() => { const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyFilters(sourceIssues, viewState); const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
return sortIssues(filteredByControls, viewState); return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch]); }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
const { data: labels } = useQuery({ const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!), queryKey: queryKeys.issues.labels(selectedCompanyId!),
@@ -253,13 +269,21 @@ export function IssuesList({
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
} }
// assignee // 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) => ({ return Object.keys(groups).map((key) => ({
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]!, 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 newIssueDefaults = (groupKey?: string) => {
const defaults: Record<string, string> = {}; const defaults: Record<string, string> = {};
@@ -267,13 +291,16 @@ export function IssuesList({
if (groupKey) { if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey; if (viewState.groupBy === "status") defaults.status = groupKey;
else if (viewState.groupBy === "priority") defaults.priority = 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; return defaults;
}; };
const assignIssue = (issueId: string, assigneeAgentId: string | null) => { const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null }); onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
setAssigneePickerIssueId(null); setAssigneePickerIssueId(null);
setAssigneeSearch(""); setAssigneeSearch("");
}; };
@@ -419,11 +446,27 @@ export function IssuesList({
</div> </div>
{/* Assignee */} {/* Assignee */}
{agents && agents.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span> <span className="text-xs text-muted-foreground">Assignee</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto"> <div className="space-y-0.5 max-h-32 overflow-y-auto">
{agents.map((agent) => ( <label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes("__unassigned")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
/>
<span className="text-sm">No assignee</span>
</label>
{currentUserId && (
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes("__me")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
/>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">Me</span>
</label>
)}
{(agents ?? []).map((agent) => (
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer"> <label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox <Checkbox
checked={viewState.assignees.includes(agent.id)} checked={viewState.assignees.includes(agent.id)}
@@ -434,7 +477,6 @@ export function IssuesList({
))} ))}
</div> </div>
</div> </div>
)}
{labels && labels.length > 0 && ( {labels && labels.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
@@ -683,6 +725,13 @@ export function IssuesList({
> >
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? ( {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" /> <Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : ( ) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30"> <span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
@@ -701,7 +750,7 @@ export function IssuesList({
> >
<input <input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50" className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search agents..." placeholder="Search assignees..."
value={assigneeSearch} value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)} onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus autoFocus
@@ -710,16 +759,32 @@ export function IssuesList({
<button <button
className={cn( className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50", "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent", !issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)} )}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
assignIssue(issue.id, null); assignIssue(issue.id, null, null);
}} }}
> >
No assignee No assignee
</button> </button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? []) {(agents ?? [])
.filter((agent) => { .filter((agent) => {
if (!assigneeSearch.trim()) return true; if (!assigneeSearch.trim()) return true;
@@ -737,7 +802,7 @@ export function IssuesList({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
assignIssue(issue.id, agent.id); assignIssue(issue.id, agent.id, null);
}} }}
> >
<Identity name={agent.name} size="sm" className="min-w-0" /> <Identity name={agent.name} size="sm" className="min-w-0" />

View File

@@ -22,6 +22,7 @@ import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health"; import { healthApi } from "../api/health";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound"; import { NotFoundPage } from "../pages/NotFound";
@@ -36,6 +37,7 @@ export function Layout() {
loading: companiesLoading, loading: companiesLoading,
selectedCompany, selectedCompany,
selectedCompanyId, selectedCompanyId,
selectionSource,
setSelectedCompanyId, setSelectedCompanyId,
} = useCompany(); } = useCompany();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
@@ -88,7 +90,13 @@ export function Layout() {
return; return;
} }
if (selectedCompanyId !== matchedCompany.id) { if (
shouldSyncCompanySelectionFromRoute({
selectionSource,
selectedCompanyId,
routeCompanyId: matchedCompany.id,
})
) {
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" }); setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
} }
}, [ }, [
@@ -99,6 +107,7 @@ export function Layout() {
location.pathname, location.pathname,
location.search, location.search,
navigate, navigate,
selectionSource,
selectedCompanyId, selectedCompanyId,
setSelectedCompanyId, setSelectedCompanyId,
]); ]);

View File

@@ -10,6 +10,11 @@ import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import {
assigneeValueFromSelection,
currentUserAssigneeOption,
parseAssigneeValue,
} from "../lib/assignees";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -63,7 +68,8 @@ interface IssueDraft {
description: string; description: string;
status: string; status: string;
priority: string; priority: string;
assigneeId: string; assigneeValue: string;
assigneeId?: string;
projectId: string; projectId: string;
assigneeModelOverride: string; assigneeModelOverride: string;
assigneeThinkingEffort: string; assigneeThinkingEffort: string;
@@ -173,7 +179,7 @@ export function NewIssueDialog() {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [status, setStatus] = useState("todo"); const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState(""); const [priority, setPriority] = useState("");
const [assigneeId, setAssigneeId] = useState(""); const [assigneeValue, setAssigneeValue] = useState("");
const [projectId, setProjectId] = useState(""); const [projectId, setProjectId] = useState("");
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
const [assigneeModelOverride, setAssigneeModelOverride] = useState(""); const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
@@ -220,7 +226,11 @@ export function NewIssueDialog() {
userId: currentUserId, 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( const supportsAssigneeOverrides = Boolean(
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
); );
@@ -295,7 +305,7 @@ export function NewIssueDialog() {
description, description,
status, status,
priority, priority,
assigneeId, assigneeValue,
projectId, projectId,
assigneeModelOverride, assigneeModelOverride,
assigneeThinkingEffort, assigneeThinkingEffort,
@@ -307,7 +317,7 @@ export function NewIssueDialog() {
description, description,
status, status,
priority, priority,
assigneeId, assigneeValue,
projectId, projectId,
assigneeModelOverride, assigneeModelOverride,
assigneeThinkingEffort, assigneeThinkingEffort,
@@ -330,7 +340,7 @@ export function NewIssueDialog() {
setStatus(newIssueDefaults.status ?? "todo"); setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? ""); setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? ""); setProjectId(newIssueDefaults.projectId ?? "");
setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
@@ -340,7 +350,11 @@ export function NewIssueDialog() {
setDescription(draft.description); setDescription(draft.description);
setStatus(draft.status || "todo"); setStatus(draft.status || "todo");
setPriority(draft.priority); setPriority(draft.priority);
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId); setAssigneeValue(
newIssueDefaults.assigneeAgentId || newIssueDefaults.assigneeUserId
? assigneeValueFromSelection(newIssueDefaults)
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
);
setProjectId(newIssueDefaults.projectId ?? draft.projectId); setProjectId(newIssueDefaults.projectId ?? draft.projectId);
setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
@@ -350,7 +364,7 @@ export function NewIssueDialog() {
setStatus(newIssueDefaults.status ?? "todo"); setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? ""); setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? ""); setProjectId(newIssueDefaults.projectId ?? "");
setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
@@ -390,7 +404,7 @@ export function NewIssueDialog() {
setDescription(""); setDescription("");
setStatus("todo"); setStatus("todo");
setPriority(""); setPriority("");
setAssigneeId(""); setAssigneeValue("");
setProjectId(""); setProjectId("");
setAssigneeOptionsOpen(false); setAssigneeOptionsOpen(false);
setAssigneeModelOverride(""); setAssigneeModelOverride("");
@@ -406,7 +420,7 @@ export function NewIssueDialog() {
function handleCompanyChange(companyId: string) { function handleCompanyChange(companyId: string) {
if (companyId === effectiveCompanyId) return; if (companyId === effectiveCompanyId) return;
setDialogCompanyId(companyId); setDialogCompanyId(companyId);
setAssigneeId(""); setAssigneeValue("");
setProjectId(""); setProjectId("");
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
@@ -443,7 +457,8 @@ export function NewIssueDialog() {
description: description.trim() || undefined, description: description.trim() || undefined,
status, status,
priority: priority || "medium", priority: priority || "medium",
...(assigneeId ? { assigneeAgentId: assigneeId } : {}), ...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
...(projectId ? { projectId } : {}), ...(projectId ? { projectId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}), ...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
@@ -475,7 +490,9 @@ export function NewIssueDialog() {
const hasDraft = title.trim().length > 0 || description.trim().length > 0; const hasDraft = title.trim().length > 0 || description.trim().length > 0;
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!; const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
const currentPriority = priorities.find((p) => p.value === priority); 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 currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null ? currentProject?.executionWorkspacePolicy ?? null
@@ -497,16 +514,18 @@ export function NewIssueDialog() {
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local; : ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]); const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
const assigneeOptions = useMemo<InlineEntityOption[]>( const assigneeOptions = useMemo<InlineEntityOption[]>(
() => () => [
sortAgentsByRecency( ...currentUserAssigneeOption(currentUserId),
...sortAgentsByRecency(
(agents ?? []).filter((agent) => agent.status !== "terminated"), (agents ?? []).filter((agent) => agent.status !== "terminated"),
recentAssigneeIds, recentAssigneeIds,
).map((agent) => ({ ).map((agent) => ({
id: agent.id, id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
label: agent.name, label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})), })),
[agents, recentAssigneeIds], ],
[agents, currentUserId, recentAssigneeIds],
); );
const projectOptions = useMemo<InlineEntityOption[]>( const projectOptions = useMemo<InlineEntityOption[]>(
() => () =>
@@ -710,8 +729,17 @@ export function NewIssueDialog() {
} }
if (e.key === "Tab" && !e.shiftKey) { if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (assigneeValue) {
// Assignee already set — skip to project or description
if (projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus(); assigneeSelectorRef.current?.focus();
} }
}
}} }}
autoFocus autoFocus
/> />
@@ -723,33 +751,49 @@ export function NewIssueDialog() {
<span>For</span> <span>For</span>
<InlineEntitySelector <InlineEntitySelector
ref={assigneeSelectorRef} ref={assigneeSelectorRef}
value={assigneeId} value={assigneeValue}
options={assigneeOptions} options={assigneeOptions}
placeholder="Assignee" placeholder="Assignee"
disablePortal disablePortal
noneLabel="No assignee" noneLabel="No assignee"
searchPlaceholder="Search assignees..." searchPlaceholder="Search assignees..."
emptyMessage="No assignees found." emptyMessage="No assignees found."
onChange={(id) => { if (id) trackRecentAssignee(id); setAssigneeId(id); }} onChange={(value) => {
const nextAssignee = parseAssigneeValue(value);
if (nextAssignee.assigneeAgentId) {
trackRecentAssignee(nextAssignee.assigneeAgentId);
}
setAssigneeValue(value);
}}
onConfirm={() => { onConfirm={() => {
if (projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus(); projectSelectorRef.current?.focus();
}
}} }}
renderTriggerValue={(option) => renderTriggerValue={(option) =>
option && currentAssignee ? ( option ? (
currentAssignee ? (
<> <>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span> <span className="truncate">{option.label}</span>
</> </>
) : (
<span className="truncate">{option.label}</span>
)
) : ( ) : (
<span className="text-muted-foreground">Assignee</span> <span className="text-muted-foreground">Assignee</span>
) )
} }
renderOption={(option) => { renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>; if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = (agents ?? []).find((agent) => agent.id === option.id); const assignee = parseAssigneeValue(option.id).assigneeAgentId
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
: null;
return ( return (
<> <>
<AgentIcon icon={assignee?.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> {assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span> <span className="truncate">{option.label}</span>
</> </>
); );

View File

@@ -494,10 +494,18 @@ export function OnboardingWizard() {
} }
async function handleStep3Next() { async function handleStep3Next() {
if (!createdCompanyId || !createdAgentId) return;
setError(null);
setStep(4);
}
async function handleLaunch() {
if (!createdCompanyId || !createdAgentId) return; if (!createdCompanyId || !createdAgentId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
let issueRef = createdIssueRef;
if (!issueRef) {
const issue = await issuesApi.create(createdCompanyId, { const issue = await issuesApi.create(createdCompanyId, {
title: taskTitle.trim(), title: taskTitle.trim(),
...(taskDescription.trim() ...(taskDescription.trim()
@@ -506,11 +514,21 @@ export function OnboardingWizard() {
assigneeAgentId: createdAgentId, assigneeAgentId: createdAgentId,
status: "todo" status: "todo"
}); });
setCreatedIssueRef(issue.identifier ?? issue.id); issueRef = issue.identifier ?? issue.id;
setCreatedIssueRef(issueRef);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(createdCompanyId) queryKey: queryKeys.issues.list(createdCompanyId)
}); });
setStep(4); }
setSelectedCompanyId(createdCompanyId);
reset();
closeOnboarding();
navigate(
createdCompanyPrefix
? `/${createdCompanyPrefix}/issues/${issueRef}`
: `/issues/${issueRef}`
);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to create task"); setError(err instanceof Error ? err.message : "Failed to create task");
} finally { } 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) { function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
@@ -1175,8 +1179,8 @@ export function OnboardingWizard() {
<div> <div>
<h3 className="font-medium">Ready to launch</h3> <h3 className="font-medium">Ready to launch</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Everything is set up. Your assigned task already woke Everything is set up. Launching now will create the
the agent, so you can jump straight to the issue. starter task, wake the agent, and open the issue.
</p> </p>
</div> </div>
</div> </div>
@@ -1291,7 +1295,7 @@ export function OnboardingWizard() {
) : ( ) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" /> <ArrowRight className="h-3.5 w-3.5 mr-1" />
)} )}
{loading ? "Opening..." : "Open Issue"} {loading ? "Creating..." : "Create & Open Issue"}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -12,8 +12,7 @@ import type { Company } from "@paperclipai/shared";
import { companiesApi } from "../api/companies"; import { companiesApi } from "../api/companies";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import type { CompanySelectionSource } from "../lib/company-selection";
type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
type CompanySelectionOptions = { source?: CompanySelectionSource }; type CompanySelectionOptions = { source?: CompanySelectionSource };
interface CompanyContextValue { interface CompanyContextValue {

View File

@@ -5,6 +5,7 @@ interface NewIssueDefaults {
priority?: string; priority?: string;
projectId?: string; projectId?: string;
assigneeAgentId?: string; assigneeAgentId?: string;
assigneeUserId?: string;
title?: string; title?: string;
description?: string; description?: string;
} }

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
getRememberedPathOwnerCompanyId,
sanitizeRememberedPathForCompany,
} from "../lib/company-page-memory";
const companies = [
{ id: "for", issuePrefix: "FOR" },
{ id: "pap", issuePrefix: "PAP" },
];
describe("getRememberedPathOwnerCompanyId", () => {
it("uses the route company instead of stale selected-company state for prefixed routes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/FOR/issues/FOR-1",
fallbackCompanyId: "pap",
}),
).toBe("for");
});
it("skips saving when a prefixed route cannot yet be resolved to a known company", () => {
expect(
getRememberedPathOwnerCompanyId({
companies: [],
pathname: "/FOR/issues/FOR-1",
fallbackCompanyId: "pap",
}),
).toBeNull();
});
it("falls back to the previous company for unprefixed board routes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/dashboard",
fallbackCompanyId: "pap",
}),
).toBe("pap");
});
});
describe("sanitizeRememberedPathForCompany", () => {
it("keeps remembered issue paths that belong to the target company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/issues/PAP-12",
companyPrefix: "PAP",
}),
).toBe("/issues/PAP-12");
});
it("falls back to dashboard for remembered issue identifiers from another company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/issues/FOR-1",
companyPrefix: "PAP",
}),
).toBe("/dashboard");
});
it("falls back to dashboard when no remembered path exists", () => {
expect(
sanitizeRememberedPathForCompany({
path: null,
companyPrefix: "PAP",
}),
).toBe("/dashboard");
});
});

View File

@@ -1,10 +1,14 @@
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useLocation, useNavigate } from "@/lib/router"; import { useLocation, useNavigate } from "@/lib/router";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { toCompanyRelativePath } from "../lib/company-routes"; import { toCompanyRelativePath } from "../lib/company-routes";
import {
getRememberedPathOwnerCompanyId,
isRememberableCompanyPath,
sanitizeRememberedPathForCompany,
} from "../lib/company-page-memory";
const STORAGE_KEY = "paperclip.companyPaths"; const STORAGE_KEY = "paperclip.companyPaths";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
function getCompanyPaths(): Record<string, string> { function getCompanyPaths(): Record<string, string> {
try { try {
@@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths)); 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. * 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. * Falls back to /dashboard if no page was previously visited for a company.
*/ */
export function useCompanyPageMemory() { export function useCompanyPageMemory() {
const { selectedCompanyId, selectedCompany, selectionSource } = useCompany(); const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const prevCompanyId = useRef<string | null>(selectedCompanyId); const prevCompanyId = useRef<string | null>(selectedCompanyId);
const rememberedPathOwnerCompanyId = useMemo(
() =>
getRememberedPathOwnerCompanyId({
companies,
pathname: location.pathname,
fallbackCompanyId: prevCompanyId.current,
}),
[companies, location.pathname],
);
// Save current path for current company on every location change. // Save current path for current company on every location change.
// Uses prevCompanyId ref so we save under the correct company even // Uses prevCompanyId ref so we save under the correct company even
// during the render where selectedCompanyId has already changed. // during the render where selectedCompanyId has already changed.
const fullPath = location.pathname + location.search; const fullPath = location.pathname + location.search;
useEffect(() => { useEffect(() => {
const companyId = prevCompanyId.current; const companyId = rememberedPathOwnerCompanyId;
const relativePath = toCompanyRelativePath(fullPath); const relativePath = toCompanyRelativePath(fullPath);
if (companyId && isRememberableCompanyPath(relativePath)) { if (companyId && isRememberableCompanyPath(relativePath)) {
saveCompanyPath(companyId, relativePath); saveCompanyPath(companyId, relativePath);
} }
}, [fullPath]); }, [fullPath, rememberedPathOwnerCompanyId]);
// Navigate to saved path when company changes // Navigate to saved path when company changes
useEffect(() => { useEffect(() => {
@@ -63,9 +67,10 @@ export function useCompanyPageMemory() {
) { ) {
if (selectionSource !== "route_sync" && selectedCompany) { if (selectionSource !== "route_sync" && selectedCompany) {
const paths = getCompanyPaths(); const paths = getCompanyPaths();
const savedPath = paths[selectedCompanyId]; const targetPath = sanitizeRememberedPathForCompany({
const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard"; path: paths[selectedCompanyId],
const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard"; companyPrefix: selectedCompany.issuePrefix,
});
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true }); navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
} }
} }

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
assigneeValueFromSelection,
currentUserAssigneeOption,
formatAssigneeUserLabel,
parseAssigneeValue,
} from "./assignees";
describe("assignee selection helpers", () => {
it("encodes and parses agent assignees", () => {
const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" });
expect(value).toBe("agent:agent-123");
expect(parseAssigneeValue(value)).toEqual({
assigneeAgentId: "agent-123",
assigneeUserId: null,
});
});
it("encodes and parses current-user assignees", () => {
const [option] = currentUserAssigneeOption("local-board");
expect(option).toEqual({
id: "user:local-board",
label: "Me",
searchText: "me board human local-board",
});
expect(parseAssigneeValue(option.id)).toEqual({
assigneeAgentId: null,
assigneeUserId: "local-board",
});
});
it("treats an empty selection as no assignee", () => {
expect(parseAssigneeValue("")).toEqual({
assigneeAgentId: null,
assigneeUserId: null,
});
});
it("keeps backward compatibility for raw agent ids in saved drafts", () => {
expect(parseAssigneeValue("legacy-agent-id")).toEqual({
assigneeAgentId: "legacy-agent-id",
assigneeUserId: null,
});
});
it("formats current and board user labels consistently", () => {
expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me");
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
});
});

51
ui/src/lib/assignees.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface AssigneeSelection {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
export interface AssigneeOption {
id: string;
label: string;
searchText?: string;
}
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
return "";
}
export function parseAssigneeValue(value: string): AssigneeSelection {
if (!value) {
return { assigneeAgentId: null, assigneeUserId: null };
}
if (value.startsWith("agent:")) {
const assigneeAgentId = value.slice("agent:".length);
return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null };
}
if (value.startsWith("user:")) {
const assigneeUserId = value.slice("user:".length);
return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null };
}
// Backward compatibility for older drafts/defaults that stored a raw agent id.
return { assigneeAgentId: value, assigneeUserId: null };
}
export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] {
if (!currentUserId) return [];
return [{
id: assigneeValueFromSelection({ assigneeUserId: currentUserId }),
label: "Me",
searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`,
}];
}
export function formatAssigneeUserLabel(
userId: string | null | undefined,
currentUserId: string | null | undefined,
): string | null {
if (!userId) return null;
if (currentUserId && userId === currentUserId) return "Me";
if (userId === "local-board") return "Board";
return userId.slice(0, 5);
}

View File

@@ -0,0 +1,65 @@
import {
extractCompanyPrefixFromPath,
normalizeCompanyPrefix,
toCompanyRelativePath,
} from "./company-routes";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
export function isRememberableCompanyPath(path: string): boolean {
const pathname = path.split("?")[0] ?? "";
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 0) return true;
const [root] = segments;
if (GLOBAL_SEGMENTS.has(root!)) return false;
return true;
}
function findCompanyByPrefix<T extends { id: string; issuePrefix: string }>(params: {
companies: T[];
companyPrefix: string;
}): T | null {
const normalizedPrefix = normalizeCompanyPrefix(params.companyPrefix);
return params.companies.find((company) => normalizeCompanyPrefix(company.issuePrefix) === normalizedPrefix) ?? null;
}
export function getRememberedPathOwnerCompanyId<T extends { id: string; issuePrefix: string }>(params: {
companies: T[];
pathname: string;
fallbackCompanyId: string | null;
}): string | null {
const routeCompanyPrefix = extractCompanyPrefixFromPath(params.pathname);
if (!routeCompanyPrefix) {
return params.fallbackCompanyId;
}
return findCompanyByPrefix({
companies: params.companies,
companyPrefix: routeCompanyPrefix,
})?.id ?? null;
}
export function sanitizeRememberedPathForCompany(params: {
path: string | null | undefined;
companyPrefix: string;
}): string {
const relativePath = params.path ? toCompanyRelativePath(params.path) : "/dashboard";
if (!isRememberableCompanyPath(relativePath)) {
return "/dashboard";
}
const pathname = relativePath.split("?")[0] ?? "";
const segments = pathname.split("/").filter(Boolean);
const [root, entityId] = segments;
if (root === "issues" && entityId) {
const identifierMatch = /^([A-Za-z]+)-\d+$/.exec(entityId);
if (
identifierMatch &&
normalizeCompanyPrefix(identifierMatch[1] ?? "") !== normalizeCompanyPrefix(params.companyPrefix)
) {
return "/dashboard";
}
}
return relativePath;
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { shouldSyncCompanySelectionFromRoute } from "./company-selection";
describe("shouldSyncCompanySelectionFromRoute", () => {
it("does not resync when selection already matches the route", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "route_sync",
selectedCompanyId: "pap",
routeCompanyId: "pap",
}),
).toBe(false);
});
it("defers route sync while a manual company switch is in flight", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "manual",
selectedCompanyId: "pap",
routeCompanyId: "ret",
}),
).toBe(false);
});
it("syncs back to the route company for non-manual mismatches", () => {
expect(
shouldSyncCompanySelectionFromRoute({
selectionSource: "route_sync",
selectedCompanyId: "pap",
routeCompanyId: "ret",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
export type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
export function shouldSyncCompanySelectionFromRoute(params: {
selectionSource: CompanySelectionSource;
selectedCompanyId: string | null;
routeCompanyId: string;
}): boolean {
const { selectionSource, selectedCompanyId, routeCompanyId } = params;
if (selectedCompanyId === routeCompanyId) return false;
// Let manual company switches finish their remembered-path navigation first.
if (selectionSource === "manual" && selectedCompanyId) {
return false;
}
return true;
}

View File

@@ -304,8 +304,7 @@ export function IssueDetail() {
options.push({ id: `agent:${agent.id}`, label: agent.name }); options.push({ id: `agent:${agent.id}`, label: agent.name });
} }
if (currentUserId) { if (currentUserId) {
const label = currentUserId === "local-board" ? "Board" : "Me (Board)"; options.push({ id: `user:${currentUserId}`, label: "Me" });
options.push({ id: `user:${currentUserId}`, label });
} }
return options; return options;
}, [agents, currentUserId]); }, [agents, currentUserId]);