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:
@@ -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
|
||||
</button>
|
||||
{issue.createdByUserId && (
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
|
||||
setAssigneeOpen(false);
|
||||
}}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
Assign to me
|
||||
</button>
|
||||
)}
|
||||
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
@@ -361,7 +370,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
}}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
|
||||
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
|
||||
</button>
|
||||
)}
|
||||
{sortedAgents
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { formatDate, cn } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
@@ -87,11 +89,20 @@ function toggleInArray(arr: string[], value: string): string[] {
|
||||
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
|
||||
}
|
||||
|
||||
function applyFilters(issues: Issue[], state: IssueViewState): Issue[] {
|
||||
function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] {
|
||||
let result = issues;
|
||||
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status));
|
||||
if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority));
|
||||
if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId));
|
||||
if (state.assignees.length > 0) {
|
||||
result = result.filter((issue) => {
|
||||
for (const assignee of state.assignees) {
|
||||
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
|
||||
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
|
||||
if (issue.assigneeAgentId === assignee) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
|
||||
return result;
|
||||
}
|
||||
@@ -165,6 +176,11 @@ export function IssuesList({
|
||||
}: IssuesListProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
|
||||
// Scope the storage key per company so folding/view state is independent across companies.
|
||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||
@@ -224,9 +240,9 @@ export function IssuesList({
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||
const filteredByControls = applyFilters(sourceIssues, viewState);
|
||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||
return sortIssues(filteredByControls, viewState);
|
||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch]);
|
||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
@@ -253,13 +269,21 @@ export function IssuesList({
|
||||
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
||||
}
|
||||
// assignee
|
||||
const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned");
|
||||
const groups = groupBy(
|
||||
filtered,
|
||||
(issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"),
|
||||
);
|
||||
return Object.keys(groups).map((key) => ({
|
||||
key,
|
||||
label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)),
|
||||
label:
|
||||
key === "__unassigned"
|
||||
? "Unassigned"
|
||||
: key.startsWith("__user:")
|
||||
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
|
||||
: (agentName(key) ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
|
||||
|
||||
const newIssueDefaults = (groupKey?: string) => {
|
||||
const defaults: Record<string, string> = {};
|
||||
@@ -267,13 +291,16 @@ export function IssuesList({
|
||||
if (groupKey) {
|
||||
if (viewState.groupBy === "status") defaults.status = groupKey;
|
||||
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
|
||||
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey;
|
||||
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
|
||||
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||
else defaults.assigneeAgentId = groupKey;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const assignIssue = (issueId: string, assigneeAgentId: string | null) => {
|
||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null });
|
||||
const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
||||
setAssigneePickerIssueId(null);
|
||||
setAssigneeSearch("");
|
||||
};
|
||||
@@ -419,22 +446,37 @@ export function IssuesList({
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
{agents && agents.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Assignee</span>
|
||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{agents.map((agent) => (
|
||||
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes(agent.id)}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
|
||||
/>
|
||||
<span className="text-sm">{agent.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Assignee</span>
|
||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes("__unassigned")}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
|
||||
/>
|
||||
<span className="text-sm">No assignee</span>
|
||||
</label>
|
||||
{currentUserId && (
|
||||
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes("__me")}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
|
||||
/>
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">Me</span>
|
||||
</label>
|
||||
)}
|
||||
{(agents ?? []).map((agent) => (
|
||||
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes(agent.id)}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
|
||||
/>
|
||||
<span className="text-sm">{agent.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{labels && labels.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
@@ -683,6 +725,13 @@ export function IssuesList({
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
@@ -701,7 +750,7 @@ export function IssuesList({
|
||||
>
|
||||
<input
|
||||
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Search agents..."
|
||||
placeholder="Search assignees..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
@@ -710,16 +759,32 @@ export function IssuesList({
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
!issue.assigneeAgentId && "bg-accent",
|
||||
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null);
|
||||
assignIssue(issue.id, null, null);
|
||||
}}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{currentUserId && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null, currentUserId);
|
||||
}}
|
||||
>
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>Me</span>
|
||||
</button>
|
||||
)}
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
@@ -737,7 +802,7 @@ export function IssuesList({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id);
|
||||
assignIssue(issue.id, agent.id, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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<InlineEntityOption[]>(
|
||||
() =>
|
||||
sortAgentsByRecency(
|
||||
() => [
|
||||
...currentUserAssigneeOption(currentUserId),
|
||||
...sortAgentsByRecency(
|
||||
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
||||
recentAssigneeIds,
|
||||
).map((agent) => ({
|
||||
id: agent.id,
|
||||
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
||||
label: agent.name,
|
||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||
})),
|
||||
[agents, recentAssigneeIds],
|
||||
],
|
||||
[agents, currentUserId, recentAssigneeIds],
|
||||
);
|
||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
@@ -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() {
|
||||
<span>For</span>
|
||||
<InlineEntitySelector
|
||||
ref={assigneeSelectorRef}
|
||||
value={assigneeId}
|
||||
value={assigneeValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Assignee"
|
||||
disablePortal
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={(id) => { if (id) trackRecentAssignee(id); setAssigneeId(id); }}
|
||||
onChange={(value) => {
|
||||
const nextAssignee = parseAssigneeValue(value);
|
||||
if (nextAssignee.assigneeAgentId) {
|
||||
trackRecentAssignee(nextAssignee.assigneeAgentId);
|
||||
}
|
||||
setAssigneeValue(value);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
projectSelectorRef.current?.focus();
|
||||
if (projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
renderTriggerValue={(option) =>
|
||||
option && currentAssignee ? (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
option ? (
|
||||
currentAssignee ? (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">Assignee</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const assignee = (agents ?? []).find((agent) => agent.id === option.id);
|
||||
const assignee = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((agent) => agent.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<AgentIcon icon={assignee?.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -494,23 +494,41 @@ export function OnboardingWizard() {
|
||||
}
|
||||
|
||||
async function handleStep3Next() {
|
||||
if (!createdCompanyId || !createdAgentId) return;
|
||||
setError(null);
|
||||
setStep(4);
|
||||
}
|
||||
|
||||
async function handleLaunch() {
|
||||
if (!createdCompanyId || !createdAgentId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const issue = await issuesApi.create(createdCompanyId, {
|
||||
title: taskTitle.trim(),
|
||||
...(taskDescription.trim()
|
||||
? { description: taskDescription.trim() }
|
||||
: {}),
|
||||
assigneeAgentId: createdAgentId,
|
||||
status: "todo"
|
||||
});
|
||||
setCreatedIssueRef(issue.identifier ?? issue.id);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.list(createdCompanyId)
|
||||
});
|
||||
setStep(4);
|
||||
let issueRef = createdIssueRef;
|
||||
if (!issueRef) {
|
||||
const issue = await issuesApi.create(createdCompanyId, {
|
||||
title: taskTitle.trim(),
|
||||
...(taskDescription.trim()
|
||||
? { description: taskDescription.trim() }
|
||||
: {}),
|
||||
assigneeAgentId: createdAgentId,
|
||||
status: "todo"
|
||||
});
|
||||
issueRef = issue.identifier ?? issue.id;
|
||||
setCreatedIssueRef(issueRef);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.list(createdCompanyId)
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedCompanyId(createdCompanyId);
|
||||
reset();
|
||||
closeOnboarding();
|
||||
navigate(
|
||||
createdCompanyPrefix
|
||||
? `/${createdCompanyPrefix}/issues/${issueRef}`
|
||||
: `/issues/${issueRef}`
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create task");
|
||||
} finally {
|
||||
@@ -518,20 +536,6 @@ export function OnboardingWizard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLaunch() {
|
||||
if (!createdAgentId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
reset();
|
||||
closeOnboarding();
|
||||
if (createdCompanyPrefix) {
|
||||
navigate(`/${createdCompanyPrefix}/dashboard`);
|
||||
return;
|
||||
}
|
||||
navigate("/dashboard");
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
@@ -1175,8 +1179,8 @@ export function OnboardingWizard() {
|
||||
<div>
|
||||
<h3 className="font-medium">Ready to launch</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything is set up. Your assigned task already woke
|
||||
the agent, so you can jump straight to the issue.
|
||||
Everything is set up. Launching now will create the
|
||||
starter task, wake the agent, and open the issue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1291,7 +1295,7 @@ export function OnboardingWizard() {
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Opening..." : "Open Issue"}
|
||||
{loading ? "Creating..." : "Create & Open Issue"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,7 @@ import type { Company } from "@paperclipai/shared";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { ApiError } from "../api/client";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
|
||||
import type { CompanySelectionSource } from "../lib/company-selection";
|
||||
type CompanySelectionOptions = { source?: CompanySelectionSource };
|
||||
|
||||
interface CompanyContextValue {
|
||||
|
||||
@@ -5,6 +5,7 @@ interface NewIssueDefaults {
|
||||
priority?: string;
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
71
ui/src/hooks/useCompanyPageMemory.test.ts
Normal file
71
ui/src/hooks/useCompanyPageMemory.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRememberedPathOwnerCompanyId,
|
||||
sanitizeRememberedPathForCompany,
|
||||
} from "../lib/company-page-memory";
|
||||
|
||||
const companies = [
|
||||
{ id: "for", issuePrefix: "FOR" },
|
||||
{ id: "pap", issuePrefix: "PAP" },
|
||||
];
|
||||
|
||||
describe("getRememberedPathOwnerCompanyId", () => {
|
||||
it("uses the route company instead of stale selected-company state for prefixed routes", () => {
|
||||
expect(
|
||||
getRememberedPathOwnerCompanyId({
|
||||
companies,
|
||||
pathname: "/FOR/issues/FOR-1",
|
||||
fallbackCompanyId: "pap",
|
||||
}),
|
||||
).toBe("for");
|
||||
});
|
||||
|
||||
it("skips saving when a prefixed route cannot yet be resolved to a known company", () => {
|
||||
expect(
|
||||
getRememberedPathOwnerCompanyId({
|
||||
companies: [],
|
||||
pathname: "/FOR/issues/FOR-1",
|
||||
fallbackCompanyId: "pap",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the previous company for unprefixed board routes", () => {
|
||||
expect(
|
||||
getRememberedPathOwnerCompanyId({
|
||||
companies,
|
||||
pathname: "/dashboard",
|
||||
fallbackCompanyId: "pap",
|
||||
}),
|
||||
).toBe("pap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRememberedPathForCompany", () => {
|
||||
it("keeps remembered issue paths that belong to the target company", () => {
|
||||
expect(
|
||||
sanitizeRememberedPathForCompany({
|
||||
path: "/issues/PAP-12",
|
||||
companyPrefix: "PAP",
|
||||
}),
|
||||
).toBe("/issues/PAP-12");
|
||||
});
|
||||
|
||||
it("falls back to dashboard for remembered issue identifiers from another company", () => {
|
||||
expect(
|
||||
sanitizeRememberedPathForCompany({
|
||||
path: "/issues/FOR-1",
|
||||
companyPrefix: "PAP",
|
||||
}),
|
||||
).toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("falls back to dashboard when no remembered path exists", () => {
|
||||
expect(
|
||||
sanitizeRememberedPathForCompany({
|
||||
path: null,
|
||||
companyPrefix: "PAP",
|
||||
}),
|
||||
).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||
import {
|
||||
getRememberedPathOwnerCompanyId,
|
||||
isRememberableCompanyPath,
|
||||
sanitizeRememberedPathForCompany,
|
||||
} from "../lib/company-page-memory";
|
||||
|
||||
const STORAGE_KEY = "paperclip.companyPaths";
|
||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
|
||||
|
||||
function getCompanyPaths(): Record<string, string> {
|
||||
try {
|
||||
@@ -22,36 +26,36 @@ function saveCompanyPath(companyId: string, path: string) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
|
||||
}
|
||||
|
||||
function isRememberableCompanyPath(path: string): boolean {
|
||||
const pathname = path.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return true;
|
||||
const [root] = segments;
|
||||
if (GLOBAL_SEGMENTS.has(root!)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the last visited page per company and navigates to it on company switch.
|
||||
* Falls back to /dashboard if no page was previously visited for a company.
|
||||
*/
|
||||
export function useCompanyPageMemory() {
|
||||
const { selectedCompanyId, selectedCompany, selectionSource } = useCompany();
|
||||
const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const prevCompanyId = useRef<string | null>(selectedCompanyId);
|
||||
const rememberedPathOwnerCompanyId = useMemo(
|
||||
() =>
|
||||
getRememberedPathOwnerCompanyId({
|
||||
companies,
|
||||
pathname: location.pathname,
|
||||
fallbackCompanyId: prevCompanyId.current,
|
||||
}),
|
||||
[companies, location.pathname],
|
||||
);
|
||||
|
||||
// Save current path for current company on every location change.
|
||||
// Uses prevCompanyId ref so we save under the correct company even
|
||||
// during the render where selectedCompanyId has already changed.
|
||||
const fullPath = location.pathname + location.search;
|
||||
useEffect(() => {
|
||||
const companyId = prevCompanyId.current;
|
||||
const companyId = rememberedPathOwnerCompanyId;
|
||||
const relativePath = toCompanyRelativePath(fullPath);
|
||||
if (companyId && isRememberableCompanyPath(relativePath)) {
|
||||
saveCompanyPath(companyId, relativePath);
|
||||
}
|
||||
}, [fullPath]);
|
||||
}, [fullPath, rememberedPathOwnerCompanyId]);
|
||||
|
||||
// Navigate to saved path when company changes
|
||||
useEffect(() => {
|
||||
@@ -63,9 +67,10 @@ export function useCompanyPageMemory() {
|
||||
) {
|
||||
if (selectionSource !== "route_sync" && selectedCompany) {
|
||||
const paths = getCompanyPaths();
|
||||
const savedPath = paths[selectedCompanyId];
|
||||
const relativePath = savedPath ? toCompanyRelativePath(savedPath) : "/dashboard";
|
||||
const targetPath = isRememberableCompanyPath(relativePath) ? relativePath : "/dashboard";
|
||||
const targetPath = sanitizeRememberedPathForCompany({
|
||||
path: paths[selectedCompanyId],
|
||||
companyPrefix: selectedCompany.issuePrefix,
|
||||
});
|
||||
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
53
ui/src/lib/assignees.test.ts
Normal file
53
ui/src/lib/assignees.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
currentUserAssigneeOption,
|
||||
formatAssigneeUserLabel,
|
||||
parseAssigneeValue,
|
||||
} from "./assignees";
|
||||
|
||||
describe("assignee selection helpers", () => {
|
||||
it("encodes and parses agent assignees", () => {
|
||||
const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" });
|
||||
|
||||
expect(value).toBe("agent:agent-123");
|
||||
expect(parseAssigneeValue(value)).toEqual({
|
||||
assigneeAgentId: "agent-123",
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes and parses current-user assignees", () => {
|
||||
const [option] = currentUserAssigneeOption("local-board");
|
||||
|
||||
expect(option).toEqual({
|
||||
id: "user:local-board",
|
||||
label: "Me",
|
||||
searchText: "me board human local-board",
|
||||
});
|
||||
expect(parseAssigneeValue(option.id)).toEqual({
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty selection as no assignee", () => {
|
||||
expect(parseAssigneeValue("")).toEqual({
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps backward compatibility for raw agent ids in saved drafts", () => {
|
||||
expect(parseAssigneeValue("legacy-agent-id")).toEqual({
|
||||
assigneeAgentId: "legacy-agent-id",
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats current and board user labels consistently", () => {
|
||||
expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me");
|
||||
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
|
||||
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
|
||||
});
|
||||
});
|
||||
51
ui/src/lib/assignees.ts
Normal file
51
ui/src/lib/assignees.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface AssigneeSelection {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
export interface AssigneeOption {
|
||||
id: string;
|
||||
label: string;
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
|
||||
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
|
||||
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseAssigneeValue(value: string): AssigneeSelection {
|
||||
if (!value) {
|
||||
return { assigneeAgentId: null, assigneeUserId: null };
|
||||
}
|
||||
if (value.startsWith("agent:")) {
|
||||
const assigneeAgentId = value.slice("agent:".length);
|
||||
return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null };
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
const assigneeUserId = value.slice("user:".length);
|
||||
return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null };
|
||||
}
|
||||
// Backward compatibility for older drafts/defaults that stored a raw agent id.
|
||||
return { assigneeAgentId: value, assigneeUserId: null };
|
||||
}
|
||||
|
||||
export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] {
|
||||
if (!currentUserId) return [];
|
||||
return [{
|
||||
id: assigneeValueFromSelection({ assigneeUserId: currentUserId }),
|
||||
label: "Me",
|
||||
searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`,
|
||||
}];
|
||||
}
|
||||
|
||||
export function formatAssigneeUserLabel(
|
||||
userId: string | null | undefined,
|
||||
currentUserId: string | null | undefined,
|
||||
): string | null {
|
||||
if (!userId) return null;
|
||||
if (currentUserId && userId === currentUserId) return "Me";
|
||||
if (userId === "local-board") return "Board";
|
||||
return userId.slice(0, 5);
|
||||
}
|
||||
65
ui/src/lib/company-page-memory.ts
Normal file
65
ui/src/lib/company-page-memory.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
extractCompanyPrefixFromPath,
|
||||
normalizeCompanyPrefix,
|
||||
toCompanyRelativePath,
|
||||
} from "./company-routes";
|
||||
|
||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
|
||||
|
||||
export function isRememberableCompanyPath(path: string): boolean {
|
||||
const pathname = path.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return true;
|
||||
const [root] = segments;
|
||||
if (GLOBAL_SEGMENTS.has(root!)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findCompanyByPrefix<T extends { id: string; issuePrefix: string }>(params: {
|
||||
companies: T[];
|
||||
companyPrefix: string;
|
||||
}): T | null {
|
||||
const normalizedPrefix = normalizeCompanyPrefix(params.companyPrefix);
|
||||
return params.companies.find((company) => normalizeCompanyPrefix(company.issuePrefix) === normalizedPrefix) ?? null;
|
||||
}
|
||||
|
||||
export function getRememberedPathOwnerCompanyId<T extends { id: string; issuePrefix: string }>(params: {
|
||||
companies: T[];
|
||||
pathname: string;
|
||||
fallbackCompanyId: string | null;
|
||||
}): string | null {
|
||||
const routeCompanyPrefix = extractCompanyPrefixFromPath(params.pathname);
|
||||
if (!routeCompanyPrefix) {
|
||||
return params.fallbackCompanyId;
|
||||
}
|
||||
|
||||
return findCompanyByPrefix({
|
||||
companies: params.companies,
|
||||
companyPrefix: routeCompanyPrefix,
|
||||
})?.id ?? null;
|
||||
}
|
||||
|
||||
export function sanitizeRememberedPathForCompany(params: {
|
||||
path: string | null | undefined;
|
||||
companyPrefix: string;
|
||||
}): string {
|
||||
const relativePath = params.path ? toCompanyRelativePath(params.path) : "/dashboard";
|
||||
if (!isRememberableCompanyPath(relativePath)) {
|
||||
return "/dashboard";
|
||||
}
|
||||
|
||||
const pathname = relativePath.split("?")[0] ?? "";
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const [root, entityId] = segments;
|
||||
if (root === "issues" && entityId) {
|
||||
const identifierMatch = /^([A-Za-z]+)-\d+$/.exec(entityId);
|
||||
if (
|
||||
identifierMatch &&
|
||||
normalizeCompanyPrefix(identifierMatch[1] ?? "") !== normalizeCompanyPrefix(params.companyPrefix)
|
||||
) {
|
||||
return "/dashboard";
|
||||
}
|
||||
}
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
34
ui/src/lib/company-selection.test.ts
Normal file
34
ui/src/lib/company-selection.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldSyncCompanySelectionFromRoute } from "./company-selection";
|
||||
|
||||
describe("shouldSyncCompanySelectionFromRoute", () => {
|
||||
it("does not resync when selection already matches the route", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "route_sync",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "pap",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("defers route sync while a manual company switch is in flight", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "manual",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "ret",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("syncs back to the route company for non-manual mismatches", () => {
|
||||
expect(
|
||||
shouldSyncCompanySelectionFromRoute({
|
||||
selectionSource: "route_sync",
|
||||
selectedCompanyId: "pap",
|
||||
routeCompanyId: "ret",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
18
ui/src/lib/company-selection.ts
Normal file
18
ui/src/lib/company-selection.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type CompanySelectionSource = "manual" | "route_sync" | "bootstrap";
|
||||
|
||||
export function shouldSyncCompanySelectionFromRoute(params: {
|
||||
selectionSource: CompanySelectionSource;
|
||||
selectedCompanyId: string | null;
|
||||
routeCompanyId: string;
|
||||
}): boolean {
|
||||
const { selectionSource, selectedCompanyId, routeCompanyId } = params;
|
||||
|
||||
if (selectedCompanyId === routeCompanyId) return false;
|
||||
|
||||
// Let manual company switches finish their remembered-path navigation first.
|
||||
if (selectionSource === "manual" && selectedCompanyId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user