feat(issues): require a team on issue creation via header team picker

Replace the workspace-name breadcrumb in both create modals with a
required single-select TeamPicker (icon + key pill). The picker always
resolves to a value (parent team for sub-issues, else explicit pick,
else default team), converges instead of clearing on project change,
and locks for sub-issues since the server enforces inheritance. Teams
render as icon + key everywhere in pickers, with a blue default icon
until custom icons/colors land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-03 10:08:21 +08:00
parent 7cff8367a8
commit fc39431008
10 changed files with 141 additions and 63 deletions

View File

@@ -42,7 +42,6 @@
},
"picker": {
"placeholder": "Team",
"clear": "Clear team",
"empty": "No active teams",
"selected_count_one": "{{count}} team",
"selected_count_other": "{{count}} teams"

View File

@@ -42,7 +42,6 @@
},
"picker": {
"placeholder": "チーム",
"clear": "チームをクリア",
"empty": "アクティブなチームはありません",
"selected_count_other": "{{count}} チーム"
},

View File

@@ -42,7 +42,6 @@
},
"picker": {
"placeholder": "팀",
"clear": "팀 지우기",
"empty": "활성 팀이 없습니다",
"selected_count_other": "{{count}}개 팀"
},

View File

@@ -42,7 +42,6 @@
},
"picker": {
"placeholder": "Team",
"clear": "清除 Team",
"empty": "没有启用的 Team",
"selected_count_other": "已选 {{count}} 个 Team"
},

View File

@@ -41,13 +41,14 @@ import { ProjectPicker } from "../projects/components/project-picker";
import { TeamPicker } from "../teams/components/team-picker";
import { useIssueTriggerPreview } from "../issues/hooks/use-issue-trigger-preview";
import { useActorName } from "@multica/core/workspace/hooks";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
import { issueDetailOptions, childIssuesOptions } from "@multica/core/issues/queries";
import { projectListOptions } from "@multica/core/projects/queries";
import { activeTeamListOptions } from "@multica/core/teams/queries";
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import {
@@ -192,7 +193,6 @@ export function ManualCreatePanel({
const { t } = useT("modals");
const router = useNavigation();
const p = useWorkspacePaths();
const workspaceName = useCurrentWorkspace()?.name;
const draft = useIssueDraftStore((s) => s.draft);
const setDraft = useIssueDraftStore((s) => s.setDraft);
@@ -272,17 +272,31 @@ export function ManualCreatePanel({
// them. When a project is selected, constrain the team picker to its teams;
// no project selected leaves the picker unconstrained.
const projectTeamIds = projectId ? selectedProject?.team_ids : undefined;
// Every issue belongs to exactly one team, so the picker always resolves to
// a value: explicit pick → workspace default team. Sub-issues are pinned to
// the parent's team (the server enforces inheritance), so the picker locks.
const { data: teams = [] } = useQuery(activeTeamListOptions(wsId));
const defaultTeamId = useMemo(
() => (teams.find((team) => team.is_default) ?? teams[0])?.id,
[teams],
);
const parentTeamId = parentIssueId ? parentIssue?.team_id ?? undefined : undefined;
const effectiveTeamId = parentTeamId ?? teamId ?? defaultTeamId;
useEffect(() => {
if (!projectId) return;
if (!projectId || parentTeamId) return;
const allowed = selectedProject?.team_ids ?? [];
// Exactly one team: auto-select it. Otherwise drop a stale pick that the
// newly-chosen project doesn't include.
if (allowed.length === 0) return;
// Converge instead of clearing: the issue must keep a team, so a pick the
// newly-chosen project doesn't include snaps to that project's own team.
if (allowed.length === 1) {
setTeamId(allowed[0]);
if (teamId !== allowed[0]) setTeamId(allowed[0]);
return;
}
if (teamId && !allowed.includes(teamId)) setTeamId(undefined);
}, [projectId, selectedProject, teamId]);
const current = teamId ?? defaultTeamId;
if (current && !allowed.includes(current)) setTeamId(allowed[0]);
}, [projectId, selectedProject, teamId, defaultTeamId, parentTeamId]);
const draftAttachments = draft.attachments ?? [];
@@ -374,7 +388,7 @@ export function ManualCreatePanel({
due_date: dueDate || undefined,
attachment_ids: activeAttachmentIds.length > 0 ? activeAttachmentIds : undefined,
parent_issue_id: parentIssueId,
team_id: teamId,
team_id: effectiveTeamId,
// Stage is only meaningful for a sub-issue (relative to its siblings).
stage: parentIssueId && stage != null ? stage : undefined,
project_id: projectId,
@@ -546,7 +560,7 @@ export function ManualCreatePanel({
? { squad_id: assigneeId }
: {}),
...(projectId ? { project_id: projectId } : {}),
...(teamId ? { team_id: teamId } : {}),
...(effectiveTeamId ? { team_id: effectiveTeamId } : {}),
...(parentIssueId ? { parent_issue_id: parentIssueId } : {}),
...(carryParentIdentifier ? { parent_issue_identifier: carryParentIdentifier } : {}),
});
@@ -559,7 +573,16 @@ export function ManualCreatePanel({
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
{/* The issue's team namespace — leads the breadcrumb like the
workspace name used to, but as a required single-select. */}
<TeamPicker
teamId={effectiveTeamId ?? null}
onChange={setTeamId}
allowedTeamIds={projectTeamIds}
disabled={!!parentTeamId}
triggerRender={<PillButton />}
align="start"
/>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">{t(($) => $.create_issue.manual_breadcrumb)}</span>
</div>
@@ -668,15 +691,6 @@ export function ManualCreatePanel({
align="start"
/>
{/* Team */}
<TeamPicker
teamId={teamId ?? null}
onChange={(next) => setTeamId(next ?? undefined)}
triggerRender={<PillButton />}
align="start"
allowedTeamIds={projectTeamIds}
/>
{/* Project */}
<ProjectPicker
projectId={projectId ?? null}

View File

@@ -9,9 +9,10 @@ import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { api, ApiError } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { useCurrentWorkspace } from "@multica/core/paths";
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
import { projectListOptions } from "@multica/core/projects/queries";
import { activeTeamListOptions } from "@multica/core/teams/queries";
import { issueDetailOptions } from "@multica/core/issues/queries";
import {
useQuickCreateStore,
type QuickCreateActorType,
@@ -83,7 +84,6 @@ export function AgentCreatePanel({
setIsExpanded: (v: boolean) => void;
}) {
const { t } = useT("modals");
const workspaceName = useCurrentWorkspace()?.name;
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id);
const { data: members = [] } = useQuery(memberListOptions(wsId));
@@ -218,6 +218,41 @@ export function AgentCreatePanel({
const parentIssueIdentifier =
(data?.parent_issue_identifier as string | undefined) ?? undefined;
// Every issue belongs to exactly one team, so the header picker always
// resolves to a value: explicit pick → remembered pick → workspace default
// team. Sub-issues are pinned to the parent's team (the server enforces
// inheritance), so the picker locks.
const { data: teams = [] } = useQuery(activeTeamListOptions(wsId));
const defaultTeamId = useMemo(
() => (teams.find((team) => team.is_default) ?? teams[0])?.id,
[teams],
);
const { data: parentIssue } = useQuery({
...issueDetailOptions(wsId, parentIssueId ?? ""),
enabled: !!parentIssueId,
});
const parentTeamId = parentIssueId ? parentIssue?.team_id ?? undefined : undefined;
const effectiveTeamId = parentTeamId ?? teamId ?? defaultTeamId ?? null;
const selectedProject = useMemo(
() => projects.find((p) => p.id === projectId) ?? null,
[projects, projectId],
);
const projectTeamIds = projectId ? selectedProject?.team_ids : undefined;
useEffect(() => {
if (!projectId || parentTeamId) return;
const allowed = selectedProject?.team_ids ?? [];
if (allowed.length === 0) return;
// Converge instead of clearing: the issue must keep a team, so a pick the
// newly-chosen project doesn't include snaps to that project's own team.
if (allowed.length === 1) {
if (teamId !== allowed[0]) setTeamId(allowed[0]);
return;
}
const current = teamId ?? defaultTeamId;
if (current && !allowed.includes(current)) setTeamId(allowed[0]);
}, [projectId, selectedProject, teamId, defaultTeamId, parentTeamId]);
// Stale-id sweep. Once the project list query has actually resolved
// (`isSuccess` — distinct from "data is the empty default during loading"),
// a `projectId` that isn't in the list means the project was deleted in
@@ -313,13 +348,13 @@ export function AgentCreatePanel({
? { agent_id: actor.id }
: { squad_id: actor.id }),
prompt: md,
team_id: teamId ?? undefined,
team_id: effectiveTeamId ?? undefined,
project_id: projectId ?? undefined,
parent_issue_id: parentIssueId,
...(activeAttachmentIds.length > 0 ? { attachment_ids: activeAttachmentIds } : {}),
});
setLastActor(actor.type, actor.id);
setLastTeamId(teamId);
setLastTeamId(effectiveTeamId);
setLastProjectId(projectId);
clearPrompt();
setLastMode("agent");
@@ -405,7 +440,7 @@ export function AgentCreatePanel({
// through.
const carry: Record<string, unknown> = {};
if (projectId) carry.project_id = projectId;
if (teamId) carry.team_id = teamId;
if (effectiveTeamId) carry.team_id = effectiveTeamId;
if (parentIssueId) carry.parent_issue_id = parentIssueId;
if (parentIssueIdentifier) carry.parent_issue_identifier = parentIssueIdentifier;
onSwitchMode?.(Object.keys(carry).length > 0 ? carry : null);
@@ -418,7 +453,16 @@ export function AgentCreatePanel({
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
{/* The issue's team namespace — leads the breadcrumb like the
workspace name used to, but as a required single-select. */}
<TeamPicker
teamId={effectiveTeamId}
onChange={setTeamId}
allowedTeamIds={projectTeamIds}
disabled={!!parentTeamId}
triggerRender={<PillButton />}
align="start"
/>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">{t(($) => $.create_issue.agent_breadcrumb)}</span>
</div>
@@ -529,12 +573,6 @@ export function AgentCreatePanel({
triggerRender={<PillButton />}
align="start"
/>
<TeamPicker
teamId={teamId}
onChange={setTeamId}
triggerRender={<PillButton />}
align="start"
/>
{parentIssueId && (
<span
data-testid="agent-sub-issue-chip"

View File

@@ -1,2 +1,3 @@
export { TeamsPage } from "./teams-page";
export { TeamPicker, TeamMultiPicker } from "./team-picker";
export { TeamIcon } from "./team-icon";

View File

@@ -0,0 +1,24 @@
import { Users } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import type { Team } from "@multica/core/types";
/**
* Team avatar: renders the team's custom icon (emoji, set in the team
* dialog) when present, otherwise a default glyph on the team color.
* Custom icon upload / per-team colors are planned; until then every team
* falls back to the same blue — keep the color here so the future
* `team.color` field has a single place to land.
*/
export function TeamIcon({ team, className }: { team: Pick<Team, "icon">; className?: string }) {
return (
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-sm text-white",
team.icon ? "bg-transparent text-sm leading-none" : "bg-blue-500",
className,
)}
>
{team.icon ? team.icon : <Users className="size-3" />}
</span>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import type { ReactElement } from "react";
import { Check, Users, X } from "lucide-react";
import { Check, Users } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { activeTeamListOptions } from "@multica/core/teams/queries";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -10,36 +10,44 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import type { Team } from "@multica/core/types";
import { TeamIcon } from "./team-icon";
import { useT } from "../../i18n";
function TeamKey({ team }: { team: Team }) {
// icon + key: how a team is identified everywhere (picker rows, triggers).
function TeamBadge({ team }: { team: Team }) {
return (
<span className="inline-flex h-5 min-w-7 items-center justify-center rounded bg-muted px-1.5 text-[10px] font-medium text-muted-foreground">
{team.key}
<span className="flex items-center gap-1.5 overflow-hidden">
<TeamIcon team={team} />
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
{team.key}
</span>
</span>
);
}
// Single-select and non-clearable by design: every issue belongs to exactly
// one team, so the picker never offers an empty state — `teamId` is only
// null while the team list is still loading.
export function TeamPicker({
teamId,
onChange,
triggerRender,
align = "start",
allowClear = false,
allowedTeamIds,
disabled = false,
}: {
teamId: string | null;
onChange: (teamId: string | null) => void;
onChange: (teamId: string) => void;
triggerRender?: ReactElement;
align?: "start" | "center" | "end";
allowClear?: boolean;
// When set, restricts the offered teams to this id set (e.g. the selected
// project's teams). Undefined means no constraint.
allowedTeamIds?: string[];
// Locked display (e.g. sub-issues inherit the parent's team server-side).
disabled?: boolean;
}) {
const { t } = useT("teams");
const wsId = useWorkspaceId();
@@ -47,46 +55,43 @@ export function TeamPicker({
const teams = allowedTeamIds
? allTeams.filter((team) => allowedTeamIds.includes(team.id))
: allTeams;
const current = teams.find((team) => team.id === teamId);
// Resolve the display label against the unfiltered list so a selection
// outside `allowedTeamIds` (e.g. before the caller converges it) still
// renders instead of flashing the placeholder.
const current = allTeams.find((team) => team.id === teamId);
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled}
className={
triggerRender
? undefined
: "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"
: "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden disabled:cursor-default disabled:hover:bg-transparent"
}
render={triggerRender}
>
{/* Trigger shows icon + key only — the key IS the team's compact
identity; the full name lives in the menu items. */}
{current ? (
<TeamKey team={current} />
<TeamBadge team={current} />
) : (
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<>
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{t(($) => $.picker.placeholder)}</span>
</>
)}
<span className="truncate">
{current ? current.name : t(($) => $.picker.placeholder)}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-56">
{teams.map((team) => (
<DropdownMenuItem key={team.id} onClick={() => onChange(team.id)}>
<TeamKey team={team} />
<TeamBadge team={team} />
<span className="truncate">{team.name}</span>
{team.id === teamId && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0" />
)}
</DropdownMenuItem>
))}
{allowClear && teamId && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onChange(null)}>
<X className="h-3.5 w-3.5 text-muted-foreground" />
{t(($) => $.picker.clear)}
</DropdownMenuItem>
</>
)}
{teams.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t(($) => $.picker.empty)}
@@ -147,7 +152,7 @@ export function TeamMultiPicker({
checked={checked}
onCheckedChange={() => toggle(team.id)}
>
<TeamKey team={team} />
<TeamBadge team={team} />
<span className="truncate">{team.name}</span>
</DropdownMenuCheckboxItem>
);

View File

@@ -1 +1 @@
export { TeamsPage, TeamPicker, TeamMultiPicker } from "./components";
export { TeamsPage, TeamPicker, TeamMultiPicker, TeamIcon } from "./components";