diff --git a/packages/views/locales/en/teams.json b/packages/views/locales/en/teams.json
index 992aad44e..837edf8bb 100644
--- a/packages/views/locales/en/teams.json
+++ b/packages/views/locales/en/teams.json
@@ -42,7 +42,6 @@
},
"picker": {
"placeholder": "Team",
- "clear": "Clear team",
"empty": "No active teams",
"selected_count_one": "{{count}} team",
"selected_count_other": "{{count}} teams"
diff --git a/packages/views/locales/ja/teams.json b/packages/views/locales/ja/teams.json
index 879999ab0..88b7e092f 100644
--- a/packages/views/locales/ja/teams.json
+++ b/packages/views/locales/ja/teams.json
@@ -42,7 +42,6 @@
},
"picker": {
"placeholder": "チーム",
- "clear": "チームをクリア",
"empty": "アクティブなチームはありません",
"selected_count_other": "{{count}} チーム"
},
diff --git a/packages/views/locales/ko/teams.json b/packages/views/locales/ko/teams.json
index 857af9779..24efc5b42 100644
--- a/packages/views/locales/ko/teams.json
+++ b/packages/views/locales/ko/teams.json
@@ -42,7 +42,6 @@
},
"picker": {
"placeholder": "팀",
- "clear": "팀 지우기",
"empty": "활성 팀이 없습니다",
"selected_count_other": "{{count}}개 팀"
},
diff --git a/packages/views/locales/zh-Hans/teams.json b/packages/views/locales/zh-Hans/teams.json
index 719c4a0df..836843c22 100644
--- a/packages/views/locales/zh-Hans/teams.json
+++ b/packages/views/locales/zh-Hans/teams.json
@@ -42,7 +42,6 @@
},
"picker": {
"placeholder": "Team",
- "clear": "清除 Team",
"empty": "没有启用的 Team",
"selected_count_other": "已选 {{count}} 个 Team"
},
diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx
index 2418feb30..fa1f754ee 100644
--- a/packages/views/modals/create-issue.tsx
+++ b/packages/views/modals/create-issue.tsx
@@ -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 */}
- {workspaceName}
+ {/* The issue's team namespace — leads the breadcrumb like the
+ workspace name used to, but as a required single-select. */}
+ }
+ align="start"
+ />
{t(($) => $.create_issue.manual_breadcrumb)}
@@ -668,15 +691,6 @@ export function ManualCreatePanel({
align="start"
/>
- {/* Team */}
-
setTeamId(next ?? undefined)}
- triggerRender={}
- align="start"
- allowedTeamIds={projectTeamIds}
- />
-
{/* Project */}
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 = {};
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 */}
- {workspaceName}
+ {/* The issue's team namespace — leads the breadcrumb like the
+ workspace name used to, but as a required single-select. */}
+ }
+ align="start"
+ />
{t(($) => $.create_issue.agent_breadcrumb)}
@@ -529,12 +573,6 @@ export function AgentCreatePanel({
triggerRender={
}
align="start"
/>
-
}
- align="start"
- />
{parentIssueId && (
; className?: string }) {
+ return (
+
+ {team.icon ? team.icon : }
+
+ );
+}
diff --git a/packages/views/teams/components/team-picker.tsx b/packages/views/teams/components/team-picker.tsx
index 5583675ee..83346e1f6 100644
--- a/packages/views/teams/components/team-picker.tsx
+++ b/packages/views/teams/components/team-picker.tsx
@@ -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 (
-
- {team.key}
+
+
+
+ {team.key}
+
);
}
+// 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 (
+ {/* Trigger shows icon + key only — the key IS the team's compact
+ identity; the full name lives in the menu items. */}
{current ? (
-
+
) : (
-
+ <>
+
+ {t(($) => $.picker.placeholder)}
+ >
)}
-
- {current ? current.name : t(($) => $.picker.placeholder)}
-
{teams.map((team) => (
onChange(team.id)}>
-
+
{team.name}
{team.id === teamId && (
)}
))}
- {allowClear && teamId && (
- <>
-
- onChange(null)}>
-
- {t(($) => $.picker.clear)}
-
- >
- )}
{teams.length === 0 && (
{t(($) => $.picker.empty)}
@@ -147,7 +152,7 @@ export function TeamMultiPicker({
checked={checked}
onCheckedChange={() => toggle(team.id)}
>
-
+
{team.name}
);
diff --git a/packages/views/teams/index.ts b/packages/views/teams/index.ts
index 4f58279ea..4b1c9f445 100644
--- a/packages/views/teams/index.ts
+++ b/packages/views/teams/index.ts
@@ -1 +1 @@
-export { TeamsPage, TeamPicker, TeamMultiPicker } from "./components";
+export { TeamsPage, TeamPicker, TeamMultiPicker, TeamIcon } from "./components";