From afc7ac4bd06e4607ec4b74269fe95c8dc356db5b Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:57:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20space=20rollout=20=E2=80=94=20space-fir?= =?UTF-8?q?st=20navigation,=20per-user=20membership,=20move-to-space=20(MU?= =?UTF-8?q?L-4142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed history of PR #4892 (pr-4784-fix). Makes spaces the primary navigation and working surface, on the "associations bind at creation time only" model. Model - Issue <-> space is the only enforced ownership (per-space numbering). Parent/child and project<->space associations only seed defaults at creation; cross-space/child and project-association validations are removed. - Moving an issue renumbers it and records the old identifier in issue_identifier_alias; API/CLI lookups and GitHub branch/PR auto-linking fall back to the alias, so old identifiers resolve forever. - Membership drives only the sidebar and personal defaults — never access. Anyone can configure any space's member set wholesale (PUT /api/spaces/{id}/members); saving an empty set archives the space behind a confirm. - Per-user space order (workspace_space_member.sort_order, fractional): drag-sorted sidebar, "my first space" is the personal issue-creation default; the workspace default space backs headless creation (agents/CLI/Slack) and system placement. Surfaces - Sidebar: joined-spaces section (drag reorder, row -> space page, per-group persisted collapse), Workspace group with a More menu, Settings demoted to a footer icon. - /space/:key/{issues,projects,autopilots,settings} — space surfaces reuse shared page components; a routed /space/new create page (replacing the earlier create-space modal), reserved key "NEW" so it can never collide with a real space's /space/:key detail page. - Issue detail moves to /issue/:id (identifier-first, Linear-style; old /issues/:id redirects); create dialogs lead with a required space pill. - Agent runtime brief now carries Space context (id/key/name) through the daemon claim -> TaskContextForEnv -> prompt pipeline, with a "## Space Context" section and --space on issue create/update in both brief renderers, matching Project's existing treatment. - zh-Hans: Space translated to 空间 across locales and conventions.zh.mdx. Fixes along the way - Cache membership judgment gains the space dimension + space_changed WS flag. - Silent skip on default-space lookup during invite acceptance is now a hard failure (no space-less members). - Backfilled space_id into ~28 raw-SQL Go test fixtures across internal/handler and cmd/server that predated migration 132's NOT NULL cutover. - Fixed a resolve-loop bug in the IssueDetail identifier wrapper (mount/ unmount cycle on resolution failure) and gave it its own loading skeleton instead of a blank screen while resolving. - reserved-slugs generator's stale hardcoded doc-comment example synced back to /create-space. Verification - go build ./..., go vet ./..., go test ./... all clean. - pnpm typecheck (core/views/web/desktop) clean. - packages/views: 162 files / 1656 tests passing. - pnpm generate:reserved-slugs produces no diff. Follow-ups tracked in docs/follow-ups/space-rollout.md. Co-Authored-By: Claude Sonnet 5 --- .../renderer/src/components/tab-bar.test.tsx | 8 + .../src/renderer/src/components/tab-bar.tsx | 55 +- .../src/pages/space-surface-pages.tsx | 34 + apps/desktop/src/renderer/src/routes.tsx | 41 + .../src/renderer/src/stores/tab-store.ts | 2 + .../docs/developers/conventions.zh.mdx | 9 +- apps/mobile/data/schemas.ts | 2 + .../(dashboard)/issue/[id]/page.tsx | 22 + .../(dashboard)/issues/[id]/page.tsx | 21 +- .../space/[spaceKey]/autopilots/page.tsx | 13 + .../space/[spaceKey]/issues/page.tsx | 13 + .../(dashboard)/space/[spaceKey]/page.tsx | 13 + .../space/[spaceKey]/projects/page.tsx | 13 + .../(dashboard)/space/new/page.tsx | 7 + docs/follow-ups/space-rollout.md | 79 ++ packages/core/api/client.ts | 77 +- packages/core/api/schema.ts | 30 + packages/core/api/schemas.test.ts | 125 +++ packages/core/api/schemas.ts | 77 ++ packages/core/issues/queries.ts | 2 + packages/core/issues/stores/view-store.ts | 5 + .../core/issues/surface/membership.test.ts | 33 +- packages/core/issues/surface/membership.ts | 7 + packages/core/issues/surface/query-plan.ts | 25 +- packages/core/issues/surface/scope.test.ts | 23 +- packages/core/issues/surface/scope.ts | 6 +- packages/core/issues/ws-updaters.ts | 6 + packages/core/layout/sidebar-store.ts | 32 + packages/core/package.json | 6 + packages/core/paths/consistency.test.ts | 1 + packages/core/paths/paths.test.ts | 4 +- packages/core/paths/paths.ts | 16 +- packages/core/paths/reserved-slugs.ts | 7 +- packages/core/permissions/index.ts | 2 + packages/core/realtime/use-realtime-sync.ts | 18 + packages/core/spaces/default-space.test.ts | 76 ++ packages/core/spaces/default-space.ts | 47 ++ packages/core/spaces/index.ts | 17 + packages/core/spaces/last-space-store.ts | 32 + packages/core/spaces/mutations.ts | 135 ++++ packages/core/spaces/queries.ts | 67 ++ packages/core/types/api.ts | 7 + packages/core/types/autopilot.ts | 3 + packages/core/types/events.ts | 10 +- packages/core/types/index.ts | 1 + packages/core/types/issue.ts | 3 + packages/core/types/project.ts | 3 + packages/core/types/space.ts | 60 ++ packages/core/workspace/index.ts | 1 + packages/core/workspace/mutations.ts | 7 +- packages/core/workspace/space-key.ts | 25 + .../ui/components/common/plain-text-field.tsx | 72 ++ packages/ui/components/ui/list-grid.tsx | 2 +- .../components/autopilot-detail-page.tsx | 1 + .../components/autopilot-dialog.tsx | 62 +- .../autopilots/components/autopilots-page.tsx | 26 +- packages/views/editor/title-editor.tsx | 6 +- packages/views/editor/utils/link-handler.ts | 1 + packages/views/i18n/resources-types.ts | 2 + .../__tests__/use-issue-actions.test.tsx | 2 +- .../actions/issue-actions-menu-items.tsx | 3 + .../views/issues/actions/use-issue-actions.ts | 7 +- .../issues/components/issue-detail.test.tsx | 2 +- .../views/issues/components/issue-detail.tsx | 200 +++-- .../views/issues/components/issues-header.tsx | 118 ++- .../views/issues/components/issues-page.tsx | 5 +- .../surface/use-issue-surface-controller.ts | 9 +- packages/views/layout/app-sidebar.test.tsx | 21 +- packages/views/layout/app-sidebar.tsx | 443 +++++++++-- packages/views/locales/en/autopilots.json | 10 +- packages/views/locales/en/issues.json | 6 +- packages/views/locales/en/layout.json | 10 +- packages/views/locales/en/modals.json | 4 +- packages/views/locales/en/onboarding.json | 29 +- packages/views/locales/en/projects.json | 1 + packages/views/locales/en/spaces.json | 89 +++ packages/views/locales/index.ts | 8 + packages/views/locales/ja/autopilots.json | 4 +- packages/views/locales/ja/issues.json | 4 + packages/views/locales/ja/layout.json | 8 +- packages/views/locales/ja/modals.json | 2 + packages/views/locales/ja/onboarding.json | 3 - packages/views/locales/ja/projects.json | 1 + packages/views/locales/ja/spaces.json | 88 +++ packages/views/locales/ko/autopilots.json | 4 +- packages/views/locales/ko/issues.json | 4 + packages/views/locales/ko/layout.json | 8 +- packages/views/locales/ko/modals.json | 2 + packages/views/locales/ko/onboarding.json | 3 - packages/views/locales/ko/projects.json | 1 + packages/views/locales/ko/spaces.json | 88 +++ .../views/locales/zh-Hans/autopilots.json | 4 +- packages/views/locales/zh-Hans/issues.json | 4 + packages/views/locales/zh-Hans/layout.json | 8 +- packages/views/locales/zh-Hans/modals.json | 2 + .../views/locales/zh-Hans/onboarding.json | 3 - packages/views/locales/zh-Hans/projects.json | 1 + packages/views/locales/zh-Hans/spaces.json | 88 +++ packages/views/modals/create-issue.tsx | 84 +- packages/views/modals/create-project.test.tsx | 1 + packages/views/modals/create-project.tsx | 75 +- .../views/modals/quick-create-issue.test.tsx | 1 + packages/views/modals/quick-create-issue.tsx | 84 +- packages/views/modals/registry.tsx | 2 +- .../views/onboarding/steps/step-workspace.tsx | 32 +- packages/views/package.json | 1 + .../projects/components/project-detail.tsx | 11 + .../projects/components/projects-page.tsx | 14 +- .../spaces/components/create-space-page.tsx | 343 ++++++++ packages/views/spaces/components/index.ts | 5 + .../spaces/components/space-detail-page.tsx | 515 ++++++++++++ .../views/spaces/components/space-icon.tsx | 26 + .../views/spaces/components/space-picker.tsx | 187 +++++ .../space-project-conflict-dialog.tsx | 87 +++ .../spaces/components/space-surface-pages.tsx | 93 +++ packages/views/spaces/index.ts | 10 + .../views/workspace/create-workspace-form.tsx | 5 +- .../welcome-after-onboarding.test.tsx | 2 +- scripts/generate-reserved-slugs.mjs | 2 +- .../cmd/migrate/space_number_cutover_test.go | 358 +++++++++ server/cmd/multica/cmd_autopilot.go | 37 +- server/cmd/multica/cmd_issue.go | 20 + server/cmd/multica/cmd_project.go | 29 +- server/cmd/multica/cmd_space.go | 289 +++++++ server/cmd/multica/main.go | 2 + .../autopilot_dispatch_for_plan_test.go | 3 + .../server/autopilot_failure_monitor_test.go | 1 + server/cmd/server/autopilot_listeners_test.go | 5 + .../cmd/server/autopilot_schedule_job_test.go | 2 + server/cmd/server/integration_test.go | 43 +- .../cmd/server/notification_listeners_test.go | 8 +- .../server/quick_create_subscriber_test.go | 20 +- server/cmd/server/rerun_session_test.go | 8 +- server/cmd/server/router.go | 18 +- server/cmd/server/runtime_sweeper_test.go | 28 +- .../cmd/server/subscriber_listeners_test.go | 4 +- .../cmd/server/workspace_scope_guard_test.go | 4 +- server/internal/daemon/daemon.go | 3 + server/internal/daemon/execenv/execenv.go | 3 + .../internal/daemon/execenv/runtime_config.go | 15 +- .../daemon/execenv/runtime_config_sections.go | 22 +- server/internal/daemon/prompt.go | 11 + server/internal/daemon/types.go | 3 + server/internal/handler/agent.go | 3 + server/internal/handler/agent_access_test.go | 10 +- server/internal/handler/autopilot.go | 56 ++ .../internal/handler/autopilot_list_test.go | 5 +- .../handler/autopilot_private_leader_test.go | 10 +- .../handler/autopilot_webhook_handler_test.go | 5 +- .../handler/cancel_task_by_user_test.go | 19 +- server/internal/handler/comment_list_test.go | 24 +- .../internal/handler/comment_resolve_test.go | 8 +- .../handler/comment_trigger_preview_test.go | 10 +- server/internal/handler/daemon.go | 33 +- server/internal/handler/daemon_test.go | 120 +-- server/internal/handler/dashboard_test.go | 40 +- server/internal/handler/github.go | 30 +- server/internal/handler/handler.go | 28 +- server/internal/handler/handler_test.go | 178 ++++- server/internal/handler/invitation.go | 18 + server/internal/handler/issue.go | 348 +++++++-- .../handler/issue_assignee_types_test.go | 4 +- server/internal/handler/issue_child_done.go | 3 +- .../issue_children_for_parents_test.go | 21 +- server/internal/handler/issue_grouped_test.go | 99 ++- .../internal/handler/issue_involves_test.go | 14 +- .../handler/issue_limit_validation_test.go | 8 +- .../handler/issue_reassign_no_cancel_test.go | 4 +- .../internal/handler/issue_scheduled_test.go | 4 +- .../handler/mention_self_trigger_test.go | 5 +- server/internal/handler/onboarding_shim.go | 35 +- server/internal/handler/project.go | 239 +++++- .../internal/handler/project_resource_test.go | 5 +- .../handler/quick_create_parent_test.go | 18 +- server/internal/handler/reserved_slugs.json | 7 +- server/internal/handler/runtime_test.go | 10 +- server/internal/handler/search_test.go | 30 +- server/internal/handler/space.go | 616 +++++++++++++++ server/internal/handler/space_resolve.go | 51 ++ server/internal/handler/space_resolve_test.go | 58 ++ server/internal/handler/squad.go | 5 +- .../internal/handler/squad_briefing_test.go | 4 +- .../handler/squad_comment_trigger_test.go | 12 +- .../internal/handler/squad_id_enqueue_test.go | 4 +- .../handler/squad_private_leader_test.go | 12 +- .../squad_worker_comment_wakes_leader_test.go | 4 +- server/internal/handler/workspace.go | 181 ++++- .../integrations/channel/engine/resolvers.go | 2 + .../integrations/channel/engine/router.go | 7 +- .../channel/engine/router_test.go | 34 +- .../integrations/slack/slash_command.go | 3 +- .../integrations/slack/slash_command_test.go | 2 +- server/internal/issueguard/duplicate.go | 7 +- server/internal/issueidentifier/identifier.go | 141 ++++ .../issueidentifier/identifier_test.go | 353 +++++++++ server/internal/issueposition/position.go | 13 +- server/internal/service/autopilot.go | 238 +++--- .../multica-autopilots/SKILL.md | 10 +- .../references/autopilots-source-map.md | 1 + .../multica-projects-and-resources/SKILL.md | 27 +- .../projects-and-resources-source-map.md | 4 + .../multica-working-on-issues/SKILL.md | 26 +- .../working-on-issues-source-map.md | 28 +- server/internal/service/issue.go | 241 +++++- server/internal/service/issue_resolve_test.go | 35 + .../service/resolve_originator_test.go | 20 +- server/internal/service/task.go | 34 +- .../internal/service/task_claim_race_test.go | 13 +- .../service/task_dedup_head_sha_test.go | 10 +- .../service/task_issue_broadcast_test.go | 10 +- .../migrations/131_workspace_space.down.sql | 18 + server/migrations/131_workspace_space.up.sql | 186 +++++ .../132_space_number_cutover.down.sql | 27 + .../132_space_number_cutover.up.sql | 161 ++++ .../134_issue_identifier_alias.down.sql | 1 + .../134_issue_identifier_alias.up.sql | 16 + .../135_space_member_sort_order.down.sql | 1 + .../135_space_member_sort_order.up.sql | 23 + server/pkg/db/generated/autopilot.sql.go | 61 +- server/pkg/db/generated/issue.sql.go | 332 +++++--- .../generated/issue_identifier_alias.sql.go | 84 ++ server/pkg/db/generated/models.go | 41 + server/pkg/db/generated/project.sql.go | 172 +++- server/pkg/db/generated/space.sql.go | 736 ++++++++++++++++++ server/pkg/db/generated/workspace.sql.go | 13 - server/pkg/db/queries/autopilot.sql | 17 +- server/pkg/db/queries/issue.sql | 50 +- .../pkg/db/queries/issue_identifier_alias.sql | 12 + server/pkg/db/queries/project.sql | 45 +- server/pkg/db/queries/space.sql | 146 ++++ server/pkg/db/queries/workspace.sql | 5 - 231 files changed, 10070 insertions(+), 1047 deletions(-) create mode 100644 apps/desktop/src/renderer/src/pages/space-surface-pages.tsx create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/issue/[id]/page.tsx create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/autopilots/page.tsx create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/issues/page.tsx create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/page.tsx create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/projects/page.tsx create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/space/new/page.tsx create mode 100644 docs/follow-ups/space-rollout.md create mode 100644 packages/core/layout/sidebar-store.ts create mode 100644 packages/core/spaces/default-space.test.ts create mode 100644 packages/core/spaces/default-space.ts create mode 100644 packages/core/spaces/index.ts create mode 100644 packages/core/spaces/last-space-store.ts create mode 100644 packages/core/spaces/mutations.ts create mode 100644 packages/core/spaces/queries.ts create mode 100644 packages/core/types/space.ts create mode 100644 packages/core/workspace/space-key.ts create mode 100644 packages/ui/components/common/plain-text-field.tsx create mode 100644 packages/views/locales/en/spaces.json create mode 100644 packages/views/locales/ja/spaces.json create mode 100644 packages/views/locales/ko/spaces.json create mode 100644 packages/views/locales/zh-Hans/spaces.json create mode 100644 packages/views/spaces/components/create-space-page.tsx create mode 100644 packages/views/spaces/components/index.ts create mode 100644 packages/views/spaces/components/space-detail-page.tsx create mode 100644 packages/views/spaces/components/space-icon.tsx create mode 100644 packages/views/spaces/components/space-picker.tsx create mode 100644 packages/views/spaces/components/space-project-conflict-dialog.tsx create mode 100644 packages/views/spaces/components/space-surface-pages.tsx create mode 100644 packages/views/spaces/index.ts create mode 100644 server/cmd/migrate/space_number_cutover_test.go create mode 100644 server/cmd/multica/cmd_space.go create mode 100644 server/internal/handler/space.go create mode 100644 server/internal/handler/space_resolve.go create mode 100644 server/internal/handler/space_resolve_test.go create mode 100644 server/internal/issueidentifier/identifier.go create mode 100644 server/internal/issueidentifier/identifier_test.go create mode 100644 server/internal/service/issue_resolve_test.go create mode 100644 server/migrations/131_workspace_space.down.sql create mode 100644 server/migrations/131_workspace_space.up.sql create mode 100644 server/migrations/132_space_number_cutover.down.sql create mode 100644 server/migrations/132_space_number_cutover.up.sql create mode 100644 server/migrations/134_issue_identifier_alias.down.sql create mode 100644 server/migrations/134_issue_identifier_alias.up.sql create mode 100644 server/migrations/135_space_member_sort_order.down.sql create mode 100644 server/migrations/135_space_member_sort_order.up.sql create mode 100644 server/pkg/db/generated/issue_identifier_alias.sql.go create mode 100644 server/pkg/db/generated/space.sql.go create mode 100644 server/pkg/db/queries/issue_identifier_alias.sql create mode 100644 server/pkg/db/queries/space.sql diff --git a/apps/desktop/src/renderer/src/components/tab-bar.test.tsx b/apps/desktop/src/renderer/src/components/tab-bar.test.tsx index f1b59d026c..08a42cf185 100644 --- a/apps/desktop/src/renderer/src/components/tab-bar.test.tsx +++ b/apps/desktop/src/renderer/src/components/tab-bar.test.tsx @@ -60,6 +60,14 @@ vi.mock("@multica/core/paths", () => ({ issues: () => `/${slug}/issues`, }), }, + useCurrentWorkspace: () => ({ id: "ws-1", slug: "acme" }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: [] }), +})); +vi.mock("@multica/core/spaces/queries", () => ({ + spaceListOptions: () => ({ queryKey: ["spaces"], queryFn: () => [] }), })); import { TabBar } from "./tab-bar"; diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx index f002d26fcf..6893973b96 100644 --- a/apps/desktop/src/renderer/src/components/tab-bar.tsx +++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx @@ -1,4 +1,4 @@ -import { Fragment } from "react"; +import { Fragment, useMemo } from "react"; import { Inbox, CircleUser, @@ -7,12 +7,15 @@ import { Monitor, BookOpenText, Settings, + Users, + FolderKanban, X, Plus, Pin, PinOff, type LucideIcon, } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { DndContext, PointerSensor, @@ -45,7 +48,10 @@ import { resolveRouteIcon, type Tab, } from "@/stores/tab-store"; -import { paths } from "@multica/core/paths"; +import { paths, useCurrentWorkspace } from "@multica/core/paths"; +import { spaceListOptions } from "@multica/core/spaces/queries"; +import type { Space } from "@multica/core/types"; +import { SpaceIcon } from "@multica/views/spaces"; const TAB_ICONS: Record = { Inbox, @@ -55,12 +61,23 @@ const TAB_ICONS: Record = { Monitor, BookOpenText, Settings, + Users, + FolderKanban, }; +/** Space key from a tab path, or null if the tab isn't a space-scoped route. + * Mirrors resolveRouteIcon's segment convention: `/{slug}/space/{key}/...`. */ +function extractSpaceKey(path: string): string | null { + const segments = path.split("/").filter(Boolean); + if (segments[1] !== "space" || !segments[2]) return null; + return decodeURIComponent(segments[2]); +} + function SortableTabItem({ tab, isActive, isOnly, + spacesByKey, }: { tab: Tab; isActive: boolean; @@ -70,6 +87,9 @@ function SortableTabItem({ * last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c). */ isOnly: boolean; + /** Space-scoped tabs render the actual space's icon instead of a generic + * route glyph — keyed by space key, same cache the sidebar reads. */ + spacesByKey: Map; }) { const setActiveTab = useTabStore((s) => s.setActiveTab); const closeTab = useTabStore((s) => s.closeTab); @@ -84,11 +104,9 @@ function SortableTabItem({ isDragging, } = useSortable({ id: tab.id }); - // Pinned tabs swap the route icon for a Pin glyph as the static "I am - // pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still - // present in the title, and this avoids a hard left accent border that read - // as visually heavy in light mode. - const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon]; + const spaceKey = extractSpaceKey(tab.path); + const space = spaceKey ? spacesByKey.get(spaceKey) : undefined; + const RouteIcon = TAB_ICONS[tab.icon]; const style = { transform: CSS.Transform.toString(transform), @@ -141,7 +159,17 @@ function SortableTabItem({ isDragging && "opacity-60", )} > - {LeadingIcon && } + {tab.pinned ? ( + // Pinned tabs swap the route icon for a Pin glyph as the static "I am + // pinned" indicator (RFC §3 D1v-iv FINAL). The route information is + // still present in the title, and this avoids a hard left accent + // border that read as visually heavy in light mode. + + ) : space ? ( + + ) : ( + RouteIcon && + )} s.moveTab); + const wsId = useCurrentWorkspace()?.id; + const { data: spaces = [] } = useQuery({ + ...spaceListOptions(wsId ?? ""), + enabled: !!wsId, + }); + const spacesByKey = useMemo( + () => new Map(spaces.map((space) => [space.key, space])), + [spaces], + ); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 }, @@ -274,6 +312,7 @@ export function TabBar() { tab={tab} isActive={tab.id === activeTabId} isOnly={tabs.length === 1} + spacesByKey={spacesByKey} /> {tab.pinned && index === pinnedCount - 1 && diff --git a/apps/desktop/src/renderer/src/pages/space-surface-pages.tsx b/apps/desktop/src/renderer/src/pages/space-surface-pages.tsx new file mode 100644 index 0000000000..e720ae330b --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/space-surface-pages.tsx @@ -0,0 +1,34 @@ +import { useParams } from "react-router-dom"; +import { + SpaceIssuesPage, + SpaceProjectsPage, + SpaceAutopilotsPage, + SpaceDetailPage, +} from "@multica/views/spaces"; + +// Router wrappers: resolve the :spaceKey param and hand it to the shared +// space surface pages (packages/views owns the actual rendering). + +export function SpaceIssuesRoute() { + const { spaceKey } = useParams<{ spaceKey: string }>(); + if (!spaceKey) return null; + return ; +} + +export function SpaceProjectsRoute() { + const { spaceKey } = useParams<{ spaceKey: string }>(); + if (!spaceKey) return null; + return ; +} + +export function SpaceAutopilotsRoute() { + const { spaceKey } = useParams<{ spaceKey: string }>(); + if (!spaceKey) return null; + return ; +} + +export function SpaceDetailRoute() { + const { spaceKey } = useParams<{ spaceKey: string }>(); + if (!spaceKey) return null; + return ; +} diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index c39b1648bd..8d8a506459 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -14,6 +14,13 @@ import { AgentDetailPage } from "./pages/agent-detail-page"; import { MemberDetailPage } from "./pages/member-detail-page"; import { RuntimeDetailPage } from "./pages/runtime-detail-page"; import { AttachmentPreviewRoute } from "./pages/attachment-preview-page"; +import { + SpaceIssuesRoute, + SpaceProjectsRoute, + SpaceAutopilotsRoute, + SpaceDetailRoute, +} from "./pages/space-surface-pages"; +import { CreateSpacePage } from "@multica/views/spaces"; import { IssuesPage } from "@multica/views/issues/components"; import { ProjectsPage } from "@multica/views/projects/components"; import { DashboardPage } from "@multica/views/dashboard"; @@ -123,6 +130,15 @@ export const appRoutes: RouteObject[] = [ handle: { title: "Issues" }, }, { + // Canonical issue-detail route: identifier-first (/issue/NAI-3), + // also accepts a UUID. + path: "issue/:id", + element: , + handle: { title: "Issue" }, + }, + { + // Legacy alias — persisted tabs from older builds still point at + // /issues/:id; keep them opening instead of dropping the tab. path: "issues/:id", element: , handle: { title: "Issue" }, @@ -132,6 +148,31 @@ export const appRoutes: RouteObject[] = [ element: , handle: { title: "Projects" }, }, + { + path: "space/new", + element: , + handle: { title: "New space" }, + }, + { + path: "space/:spaceKey/issues", + element: , + handle: { title: "Space issues" }, + }, + { + path: "space/:spaceKey/projects", + element: , + handle: { title: "Space projects" }, + }, + { + path: "space/:spaceKey/autopilots", + element: , + handle: { title: "Space autopilots" }, + }, + { + path: "space/:spaceKey", + element: , + handle: { title: "Space detail" }, + }, { path: "projects/:id", element: , diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index 33613429eb..c435e4b35e 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -132,7 +132,9 @@ interface TabStore { const ROUTE_ICONS: Record = { inbox: "Inbox", "my-issues": "CircleUser", + issue: "ListTodo", issues: "ListTodo", + space: "Users", projects: "FolderKanban", autopilots: "ListTodo", agents: "Bot", diff --git a/apps/docs/content/docs/developers/conventions.zh.mdx b/apps/docs/content/docs/developers/conventions.zh.mdx index b3a036e5b7..11a1a69acb 100644 --- a/apps/docs/content/docs/developers/conventions.zh.mdx +++ b/apps/docs/content/docs/developers/conventions.zh.mdx @@ -16,7 +16,7 @@ description: 代码命名规范、i18n 翻译术语表、中文风格指南的 工作区前置路由(用户进入工作区之前能访问的路由)必须用单个单词,或者 `/{noun}/{verb}` 格式。 - ✅ `/login`、`/inbox`、`/workspaces/new` -- ❌ `/new-workspace`、`/create-team`、`/accept-invite` +- ❌ `/new-workspace`、`/create-space`、`/accept-invite` 根目录的连字符词组会跟用户自选 workspace slug 冲突,逼着团队不停审保留字列表。把名词(`workspaces`)保留下来,整个 `/workspaces/*` 子树自动受保护。 @@ -111,12 +111,16 @@ Multica 的产品名词分两类: - `task` ↔ `执行任务`(上下文清楚后可简写为「任务」) - `issue` 没有公认中文译法 —— 保留英文;标题可大写为 `Issue` - `skill` 没有公认中文译法 —— 保留英文;标题可大写为 `Skills` +- `space` 翻成「空间」—— 它是 issue 编号命名空间的所有者,key(如 `MUL`)仍保持英文 + 不译,只构成 issue 标识符(`MUL-42`)本身;"空间"是 Notion / 语雀 / 飞书文档等同类 + 产品对"工作区下子分组"的通用译法,不会与「团队」(泛指人群)混淆,也不会与「工作区」 + (workspace)产生歧义 **为什么 `issue` / `skill` / `task` 不强制译,而 `project` / `autopilot` 必译**: - **`issue` / `task`**:dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配 —— 三个候选都不如 `issue` 准确。**但**在长篇 doc 正文里,重复 50 次 `task` 节奏不顺,所以正文允许用 `执行任务`,UI 短句、状态名仍保持小写英文。 - **`skill`**:Multica 特有概念,没有公认中文译法。 -- **`project` 翻成「项目」**:中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成「项目」,没有产品保留 `project`。 +- **`project` 翻成「项目」**:中文里早就稳定的日常词。飞书 / Tower / Spacebition / PingCode / GitHub Projects 中文版 0 例外都翻译成「项目」,没有产品保留 `project`。 - **`autopilot` 翻成「自动化」**:autopilot 在中文里联想到特斯拉的「自动驾驶」,跟产品功能(按周期跑 task)对应不上。Notion / 飞书都用「自动化」,是行业共识。 ### 完整翻译 —— 概念词 @@ -124,6 +128,7 @@ Multica 的产品名词分两类: | 英 | 中 | | --- | --- | | Workspace | **工作区** | +| Space | **空间**(见上) | | Agent | **智能体** | | Project | **项目** | | Autopilot | **自动化** | diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts index 596ad82346..c4c2fd403b 100644 --- a/apps/mobile/data/schemas.ts +++ b/apps/mobile/data/schemas.ts @@ -163,6 +163,7 @@ export const ProjectSchema = z.object({ priority: z.string(), lead_type: z.string().nullable(), lead_id: z.string().nullable(), + space_ids: z.array(z.string()).default([]), created_at: z.string(), updated_at: z.string(), issue_count: z.number().default(0), @@ -196,6 +197,7 @@ export const EMPTY_PROJECT: Project = { priority: "none", lead_type: null, lead_id: null, + space_ids: [], created_at: "", updated_at: "", issue_count: 0, diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/issue/[id]/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/issue/[id]/page.tsx new file mode 100644 index 0000000000..46eea65115 --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/issue/[id]/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { use } from "react"; +import { IssueDetail } from "@multica/views/issues/components"; +import { ErrorBoundary } from "@multica/ui/components/common/error-boundary"; + +// Issue detail — identifier-first (Linear-style /issue/NAI-3), also accepts +// a UUID. The space rides in the identifier, not in a path segment, so a +// space move never orphans the URL (old identifiers resolve via the +// server-side alias). +export default function IssueDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + return ( + + + + ); +} diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/issues/[id]/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/issues/[id]/page.tsx index 6f90958db8..64424278ee 100644 --- a/apps/web/app/[workspaceSlug]/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/[workspaceSlug]/(dashboard)/issues/[id]/page.tsx @@ -1,18 +1,13 @@ -"use client"; +import { redirect } from "next/navigation"; -import { use } from "react"; -import { IssueDetail } from "@multica/views/issues/components"; -import { ErrorBoundary } from "@multica/ui/components/common/error-boundary"; - -export default function IssueDetailPage({ +// Legacy issue-detail path. The canonical route moved to /issue/:id +// (identifier-first, Linear-style); old bookmarks and persisted tabs land +// here and get forwarded. +export default async function LegacyIssueDetailRedirect({ params, }: { - params: Promise<{ id: string }>; + params: Promise<{ workspaceSlug: string; id: string }>; }) { - const { id } = use(params); - return ( - - - - ); + const { workspaceSlug, id } = await params; + redirect(`/${encodeURIComponent(workspaceSlug)}/issue/${encodeURIComponent(id)}`); } diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/autopilots/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/autopilots/page.tsx new file mode 100644 index 0000000000..9bbc8c6d6e --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/autopilots/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { use } from "react"; +import { SpaceAutopilotsPage } from "@multica/views/spaces"; + +export default function Page({ + params, +}: { + params: Promise<{ spaceKey: string }>; +}) { + const { spaceKey } = use(params); + return ; +} diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/issues/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/issues/page.tsx new file mode 100644 index 0000000000..1ccc094f3a --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/issues/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { use } from "react"; +import { SpaceIssuesPage } from "@multica/views/spaces"; + +export default function Page({ + params, +}: { + params: Promise<{ spaceKey: string }>; +}) { + const { spaceKey } = use(params); + return ; +} diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/page.tsx new file mode 100644 index 0000000000..66a13b7bcb --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { use } from "react"; +import { SpaceDetailPage } from "@multica/views/spaces"; + +export default function Page({ + params, +}: { + params: Promise<{ spaceKey: string }>; +}) { + const { spaceKey } = use(params); + return ; +} diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/projects/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/projects/page.tsx new file mode 100644 index 0000000000..9791e8680c --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/space/[spaceKey]/projects/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { use } from "react"; +import { SpaceProjectsPage } from "@multica/views/spaces"; + +export default function Page({ + params, +}: { + params: Promise<{ spaceKey: string }>; +}) { + const { spaceKey } = use(params); + return ; +} diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/space/new/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/space/new/page.tsx new file mode 100644 index 0000000000..fddf88f3ae --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/space/new/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { CreateSpacePage } from "@multica/views/spaces"; + +export default function Page() { + return ; +} diff --git a/docs/follow-ups/space-rollout.md b/docs/follow-ups/space-rollout.md new file mode 100644 index 0000000000..539fa28c4c --- /dev/null +++ b/docs/follow-ups/space-rollout.md @@ -0,0 +1,79 @@ +# Space rollout — 决策记录与 follow-up 清单 + +> 开发期工作文档(非 apps/docs 官方文档)。记录 space 功能的已定模型、正在做的事、以及容易被忘掉的后续项。 +> 最后更新:2026-07-03(pr-4784-fix 分支讨论) + +## 已定模型(核心原则) + +**一切关联只在"创建时刻"起作用(做默认值),之后互不约束。** + +- issue ↔ space:一对一,唯一被强制的归属;space 决定 identifier 命名空间(编号 per-space 分配)。 +- 父 issue ↔ 子 issue:创建子 issue 时默认继承父的 space,之后各自独立;移动父 issue 子 issue 不跟随。(与 Linear 一致:官方文档明确 sub-issue 可属于任意 space,继承仅发生在创建时。) +- project ↔ space:创建 issue 时用于推断默认 space;之后 issue 换 space **project 保留不动**(比 Linear 更简 —— Linear 移动时会自动移除不兼容 project,因为它有 per-space workflow/labels/cycles,我们没有)。 +- assignee 与 space 完全解耦:assignee 池 = workspace 成员 + agents,不按 space 过滤(v1 刻意如此,成员管理闭环缺失时过滤只会把人筛没)。 +- 权限:space 是组织标签,不是可见性边界;issue 可见性仍只由 workspace membership 决定。 + +**换 space 的清理准则**(将来加 space 级属性时照此办理): +只有"取值空间由 space 决定"的属性才需要在换 space 时清理/映射。 +当前唯一此类属性是编号/identifier(用重编号 + 别名表解决);status/priority/labels/project 均为 workspace 级,换 space 一概不动。 + +## 正在实现(move to space,本分支) + +1. `issue_identifier_alias` 表:换 space 重编号后,旧 identifier(如 `MUL-3`)仍可解析到该 issue(Linear `previousIdentifiers` 的轻量版)。两个解析点加 fallback: + - `handler.go` `resolveIssueByIdentifier`(CLI / API 按 identifier 查) + - `github.go` `lookupIssueByIdentifier`(分支名/PR → issue 自动关联,不加会直接坏) +2. `UpdateIssue` 支持 `space_id`:校验目标 space active → `IncrementSpaceIssueCounter` 重新取号 + space 内重新定位 → 写旧号别名 → 广播。 +3. 删除与新模型矛盾的校验(同一状态不允许 A 路径能到、B 路径 400): + - 创建/改 parent 时的父子同 space 校验(`ErrCrossSpaceChild`) + - 创建时显式 space × project 关联校验(`ErrProjectSpaceMismatch`) + - 改 issue project 时"project 与 issue 团队无关联" 400 + - project 移除 space 时"还有 issue" 409 +4. 前端换 space 入口:右键/三点共用菜单体(`issue-actions-menu-items.tsx`)加 Space 子菜单(第一位);issue 详情右栏 Properties 首行加 Space;创建弹窗解除 `allowedSpaceIds` 限制与 sub-issue 锁定,降级为默认值种子。 +5. 换 space 不做乐观编号(新号由服务端分配),等响应/invalidate。 + +## Follow-ups(防遗忘清单) + +### 缓存正确性(move to space 落地后从"优化"变"必须") +- [ ] WS `issue:updated` 增加 `spaceChanged` flag(后端 meta 目前只有 assignee/status/project)。 +- [ ] `packages/core/issues/surface/membership.ts` 增加 space 维度(`IssueChangedDims`/`listFilterDependsOn`/`issueMatchesListFilter` 目前都不含 space);cache-coordinator 注释已预留插槽(`cache-coordinator.ts` "and future space")。 + +### Sidebar 重构(方案已定,分两步) +- [ ] 后端 membership 闭环:AcceptInvitation 自动入默认团队;CreateSpace 创建者自动成为 lead;`workspace_space_member.sort_order DOUBLE PRECISION`(fractional ordering,Linear 同构:SpaceMembership.sortOrder);`GET /api/spaces` 响应加 `is_member`/`sort_order`;`PATCH /api/spaces/{id}/membership` 单行改排序。 +- [ ] space scope 打通:`packages/core/issues/surface/query-plan.ts:166` 目前对 space scope 抛 `UnsupportedIssueScopeError`;加 `/space/:key/issues|projects|automations` 路由三件套(paths + web + desktop)。 +- [ ] sidebar 本体:`Workspace ▾`(Projects/Agents/Squads/Autopilots/Skills/More▾(Usage/Runtimes))+ `Spaces ▾`(只显示我加入的、按我的 sort_order、可拖拽 —— 复用 Pinned 组 dnd-kit 基建)+ Settings 底部;全局 Issues 移出导航;折叠态 Zustand persist(按 wsId)。 +- [ ] 创建 issue 默认 space 从"默认团队"切到"我的排序第一个 space"(取值函数替换即可,种子链已铺好)。 + +### 待拍板(讨论过但未定) +- [ ] quick-create 的 `lastSpaceId` 记忆与"排序第一 = 默认"规则冲突,推荐删记忆(排序上线时一并处理)。 +- [ ] v1 无加入团队机制:"只显示我加入的" + "没有 join API" = 别人建的团队对我不可见。推荐最小闭环:建团队对话框加成员多选 + `POST /api/spaces/{id}/members`。 + +### 将来做 space 级属性时 +- [ ] label 下放到 space 维度时:换 space 逻辑里加"移除/映射目标 space 不存在的 label"(参照 Linear:space labels → Removed)。 +- [ ] 若引入 per-space workflow/cycle,同理加映射/清理。 + +### 小项 +- [ ] 别名表的锦上添花:搜索输入旧 identifier 也能命中(目前只做解析 fallback)。 +- [ ] Space icon/color 自定义上传;落点在 `packages/views/spaces/components/space-icon.tsx`(当前蓝色默认块即占位,`bg-blue-500` 待 `space.color` 数据驱动替换)。 +- [ ] 批量 move to space(batch update 支持 `space_id`,前端批量工具栏加入口)。 +- [ ] 撤销:Linear 移动支持 Cmd+Z,我们无 undo 体系,记为已知差异。 +- [ ] `apps/docs/content/docs/developers/conventions.mdx:73` issue 编号章节仍是旧语义(workspace 前缀、最长 10 位、改前缀重编号),需按 space key(≤7 位、per-space 编号、编号不变)重写。 +- [ ] issue 详情页 space 字段展示(`space_key`/`space_name` 有类型无 UI 消费点)—— 本次加 Properties 行后即覆盖,验证后可勾掉。 + +## 全产品面扫描补充(2026-07-03,对照 Linear) + +### P1 — 用户会很快撞上 +- [ ] 邀请成员时选团队(Linear 同款):Invitation 加 space_ids,AcceptInvitation 按其入队(现在只进默认团队)。成员管理闭环的最大剩余豁口。 +- [ ] 全局搜索(⌘K)的 space facet:按 space 过滤/分组搜索结果。 +- [ ] 移动端 space parity:identifier 显示、space 筛选、创建归属(mobile 目前只有 schema 字段)。按 apps/mobile/CLAUDE.md 流程单独排期。 + +### P2 — 架构已留位,按需接线 +- [ ] Slack 频道 ↔ space 路由:channel 配置加 space_id,`/issue` 建到对应团队(quick-create 已支持显式 space,纯配置接线;现在一律默认团队)。 +- [ ] 订阅整个 space(入队即默认订阅该团队新 issue)—— membership 表已就位,通知系统的自然延伸。 +- [ ] space 级 dashboard/usage 统计(数据列已在,加 group-by)。 +- [ ] 团队 unarchive(现在只能归档不能恢复;一个端点 + 列表按钮)。 +- [ ] GitHub 仓库 ↔ space 关联(Linear 用于自动化路由;暂无必要)。 + +### 有意不做(决策记录,勿误判为遗漏) +- per-space workflow/状态、space 级 labels、cycles:Linear 换 space 清理复杂度的全部来源;我们 status/labels 为 workspace 级是刻意简化(清理判定准则见上文)。 +- 私有团队(space 作为可见性边界):v1 定为纯组织标签;若做,动整个查询层权限模型,属独立大项目。 +- squad/agent 不归属 space:workspace 资产、跨团队服务;其名下 issue 各有各的 space,无冲突。 diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 2566187b90..4a4dbf1a6c 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -74,6 +74,12 @@ import type { HasPendingChatTasksResponse, SendChatMessageResponse, CancelTaskResponse, + Space, + SpaceMembership, + ListSpaceMembersResponse, + CreateSpaceRequest, + UpdateSpaceRequest, + ListSpacesResponse, Project, CreateProjectRequest, UpdateProjectRequest, @@ -144,7 +150,7 @@ import type { import { type Logger, noopLogger } from "../logger"; import { createRequestId } from "../utils"; import { getCurrentSlug } from "../platform/workspace-storage"; -import { parseWithFallback } from "./schema"; +import { parseWithFallback, parseOrWarn } from "./schema"; import { AgentTemplateSchema, AgentTemplateSummaryListSchema, @@ -170,6 +176,7 @@ import { EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE, EMPTY_GROUPED_ISSUES_RESPONSE, EMPTY_LIST_ISSUES_RESPONSE, + EMPTY_LIST_SPACES_RESPONSE, EMPTY_SEARCH_ISSUES_RESPONSE, EMPTY_SEARCH_PROJECTS_RESPONSE, EMPTY_SQUAD, @@ -185,6 +192,11 @@ import { ListAutopilotsResponseSchema, EMPTY_LIST_AUTOPILOTS_RESPONSE, ListIssuesResponseSchema, + ListSpacesResponseSchema, + ListSpaceMembersResponseSchema, + EMPTY_LIST_SPACE_MEMBERS_RESPONSE, + SpaceSchema, + SpaceMembershipSchema, ListWebhookDeliveriesResponseSchema, RuntimeHourlyActivityListSchema, RuntimeUsageByAgentListSchema, @@ -508,6 +520,7 @@ export class ApiClient { if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); if (params?.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); if (params?.creator_id) search.set("creator_id", params.creator_id); + if (params?.space_id) search.set("space_id", params.space_id); if (params?.project_id) search.set("project_id", params.project_id); if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id); if (params?.metadata && Object.keys(params.metadata).length > 0) { @@ -538,6 +551,7 @@ export class ApiClient { if (params.assignee_id) search.set("assignee_id", params.assignee_id); if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); if (params.creator_id) search.set("creator_id", params.creator_id); + if (params.space_id) search.set("space_id", params.space_id); if (params.project_id) search.set("project_id", params.project_id); if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id); if (params.metadata && Object.keys(params.metadata).length > 0) { @@ -609,6 +623,7 @@ export class ApiClient { agent_id?: string; squad_id?: string; prompt: string; + space_id?: string | null; project_id?: string | null; parent_issue_id?: string | null; attachment_ids?: string[]; @@ -1583,6 +1598,60 @@ export class ApiClient { }); } + // Spaces + async listSpaces(): Promise { + const raw = await this.fetch("/api/spaces"); + return parseWithFallback(raw, ListSpacesResponseSchema, EMPTY_LIST_SPACES_RESPONSE, { + endpoint: "GET /api/spaces", + }); + } + + async createSpace(data: CreateSpaceRequest): Promise { + const raw = await this.fetch("/api/spaces", { + method: "POST", + body: JSON.stringify(data), + }); + return parseOrWarn(raw, SpaceSchema, { endpoint: "POST /api/spaces" }); + } + + async updateSpace(id: string, data: UpdateSpaceRequest): Promise { + const raw = await this.fetch(`/api/spaces/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); + return parseOrWarn(raw, SpaceSchema, { endpoint: "PATCH /api/spaces/:id" }); + } + + async updateSpaceMembership(id: string, data: { sort_order: number }): Promise { + const raw = await this.fetch(`/api/spaces/${id}/membership`, { + method: "PATCH", + body: JSON.stringify(data), + }); + return parseOrWarn(raw, SpaceMembershipSchema, { endpoint: "PATCH /api/spaces/:id/membership" }); + } + + async replaceSpaceMembers(id: string, memberIds: string[]): Promise { + const raw = await this.fetch(`/api/spaces/${id}/members`, { + method: "PUT", + body: JSON.stringify({ member_ids: memberIds }), + }); + return parseWithFallback(raw, ListSpaceMembersResponseSchema, EMPTY_LIST_SPACE_MEMBERS_RESPONSE, { + endpoint: "PUT /api/spaces/:id/members", + }); + } + + async listSpaceMembers(id: string): Promise { + const raw = await this.fetch(`/api/spaces/${id}/members`); + return parseWithFallback(raw, ListSpaceMembersResponseSchema, EMPTY_LIST_SPACE_MEMBERS_RESPONSE, { + endpoint: "GET /api/spaces/:id/members", + }); + } + + async archiveSpace(id: string): Promise { + const raw = await this.fetch(`/api/spaces/${id}`, { method: "DELETE" }); + return parseOrWarn(raw, SpaceSchema, { endpoint: "DELETE /api/spaces/:id" }); + } + // Members async listMembers(workspaceId: string): Promise { return this.fetch(`/api/workspaces/${workspaceId}/members`); @@ -1911,9 +1980,10 @@ export class ApiClient { } // Projects - async listProjects(params?: { status?: string }): Promise { + async listProjects(params?: { status?: string; space_id?: string }): Promise { const search = new URLSearchParams(); if (params?.status) search.set("status", params.status); + if (params?.space_id) search.set("space_id", params.space_id); return this.fetch(`/api/projects?${search}`); } @@ -2104,9 +2174,10 @@ export class ApiClient { } // Autopilots - async listAutopilots(params?: { status?: string }): Promise { + async listAutopilots(params?: { status?: string; space_id?: string }): Promise { const search = new URLSearchParams(); if (params?.status) search.set("status", params.status); + if (params?.space_id) search.set("space_id", params.space_id); const raw = await this.fetch(`/api/autopilots?${search}`); return parseWithFallback( raw, diff --git a/packages/core/api/schema.ts b/packages/core/api/schema.ts index 37047f5b4b..0236b8b74e 100644 --- a/packages/core/api/schema.ts +++ b/packages/core/api/schema.ts @@ -53,3 +53,33 @@ export function parseWithFallback( ); return fallback; } + +/** + * Validate a mutation response against a zod schema, warning (not throwing) on + * drift. Unlike `parseWithFallback`, there is no synthetic fallback: a write + * response has no safe empty stand-in — an `id: ""` placeholder would corrupt + * the caches these responses feed. On drift we log via the same channel as + * `parseWithFallback` and return the raw value cast to `T`; the caller's + * optimistic cache patch plus `onSettled` invalidation remain the authoritative + * safety net. + * + * See CLAUDE.md "API Compatibility" for when to reach for this over + * `parseWithFallback`. + */ +export function parseOrWarn( + data: unknown, + schema: ZodType, + opts: ParseOptions, +): T { + const result = schema.safeParse(data); + if (result.success) return result.data as T; + schemaLogger.warn( + `API response failed schema validation: ${opts.endpoint}`, + { + endpoint: opts.endpoint, + issues: result.error.issues, + received: data, + }, + ); + return data as T; +} diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index 12d275a381..3faf5efccf 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -12,6 +12,11 @@ import { InboxUnreadSummarySchema, IssueTriggerPreviewSchema, ListIssuesResponseSchema, + ListSpacesResponseSchema, + SpaceSchema, + EMPTY_LIST_SPACES_RESPONSE, + SearchProjectsResponseSchema, + EMPTY_SEARCH_PROJECTS_RESPONSE, RuntimeHourlyActivityListSchema, RuntimeUsageByAgentListSchema, RuntimeUsageByHourListSchema, @@ -509,3 +514,123 @@ describe("InboxUnreadSummarySchema", () => { ).toBe(EMPTY_INBOX_UNREAD_SUMMARY); }); }); + +describe("SpaceSchema / ListSpacesResponseSchema drift", () => { + const ENDPOINT = { endpoint: "GET /api/spaces" }; + const baseSpace = { + id: "space-1", + workspace_id: "ws-1", + name: "Frontend", + key: "FE", + description: "", + icon: null, + issue_counter: 3, + archived_at: null, + created_by: "user-1", + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + }; + + it("parses a well-formed space and tolerates extra fields", () => { + const parsed = SpaceSchema.parse({ ...baseSpace, future_field: "ignored" }); + expect(parsed.id).toBe("space-1"); + expect(parsed.key).toBe("FE"); + }); + + it("defaults scalar fields when an older backend omits them", () => { + const parsed = SpaceSchema.parse({ id: "space-2", workspace_id: "ws-1" }); + expect(parsed.name).toBe(""); + expect(parsed.key).toBe(""); + expect(parsed.description).toBe(""); + expect(parsed.icon).toBeNull(); + expect(parsed.issue_counter).toBe(0); + expect(parsed.archived_at).toBeNull(); + }); + + it("parses a list response and coerces total", () => { + const parsed = parseWithFallback( + { spaces: [baseSpace], total: 1 }, + ListSpacesResponseSchema, + EMPTY_LIST_SPACES_RESPONSE, + ENDPOINT, + ); + expect(parsed.spaces).toHaveLength(1); + expect(parsed.total).toBe(1); + }); + + it("defaults spaces and total when the body omits them", () => { + const parsed = parseWithFallback( + {}, + ListSpacesResponseSchema, + EMPTY_LIST_SPACES_RESPONSE, + ENDPOINT, + ); + expect(parsed.spaces).toEqual([]); + expect(parsed.total).toBe(0); + }); + + it("falls back to empty when a space row has a wrong-typed id", () => { + expect( + parseWithFallback( + { spaces: [{ ...baseSpace, id: 42 }], total: 1 }, + ListSpacesResponseSchema, + EMPTY_LIST_SPACES_RESPONSE, + ENDPOINT, + ), + ).toBe(EMPTY_LIST_SPACES_RESPONSE); + }); + + it("falls back to empty for a non-object body", () => { + expect( + parseWithFallback(null, ListSpacesResponseSchema, EMPTY_LIST_SPACES_RESPONSE, ENDPOINT), + ).toBe(EMPTY_LIST_SPACES_RESPONSE); + }); +}); + +describe("ProjectSchema space_ids drift", () => { + const ENDPOINT = { endpoint: "GET /api/projects/search" }; + const baseProject = { + id: "proj-1", + workspace_id: "ws-1", + title: "Website", + description: null, + icon: null, + status: "active", + priority: "medium", + lead_type: null, + lead_id: null, + created_at: "2026-05-01T00:00:00Z", + updated_at: "2026-05-01T00:00:00Z", + match_source: "title", + }; + + it("preserves a populated space_ids array", () => { + const parsed = parseWithFallback( + { projects: [{ ...baseProject, space_ids: ["space-1", "space-2"] }], total: 1 }, + SearchProjectsResponseSchema, + EMPTY_SEARCH_PROJECTS_RESPONSE, + ENDPOINT, + ); + expect(parsed.projects[0]?.space_ids).toEqual(["space-1", "space-2"]); + }); + + it("defaults space_ids to an empty array when the field is missing", () => { + const parsed = parseWithFallback( + { projects: [baseProject], total: 1 }, + SearchProjectsResponseSchema, + EMPTY_SEARCH_PROJECTS_RESPONSE, + ENDPOINT, + ); + expect(parsed.projects[0]?.space_ids).toEqual([]); + }); + + it("coerces a null space_ids to an empty array", () => { + const parsed = parseWithFallback( + { projects: [{ ...baseProject, space_ids: null }], total: 1 }, + SearchProjectsResponseSchema, + EMPTY_SEARCH_PROJECTS_RESPONSE, + ENDPOINT, + ); + expect(parsed.projects[0]?.space_ids).toEqual([]); + }); +}); diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 3932adcc3d..108aeb9ec3 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -17,10 +17,13 @@ import type { GroupedIssuesResponse, InboxWorkspaceUnread, ListIssuesResponse, + ListSpacesResponse, + ListSpaceMembersResponse, ListWebhookDeliveriesResponse, SearchIssuesResponse, SearchProjectsResponse, Squad, + Space, TimelineEntry, User, WebhookDelivery, @@ -265,6 +268,9 @@ const IssueMetadataSchema = z.record(z.string(), z.union([z.string(), z.number() export const IssueSchema = z.object({ id: z.string(), workspace_id: z.string(), + space_id: z.string().nullable().default(null), + space_key: z.string().nullable().default(null), + space_name: z.string().nullable().default(null), number: z.number(), identifier: z.string(), title: z.string(), @@ -317,6 +323,73 @@ export const EMPTY_SEARCH_ISSUES_RESPONSE: SearchIssuesResponse = { total: 0, }; +export const SpaceSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + name: z.string().default(""), + key: z.string().default(""), + description: z.string().default(""), + icon: z.string().nullable().default(null), + issue_counter: z.number().default(0), + archived_at: z.string().nullable().default(null), + created_by: z.string().nullable().default(null), + created_at: z.string().default(""), + updated_at: z.string().default(""), + is_member: z.boolean().default(false), + sort_order: z.number().default(0), +}).loose(); + +// PATCH /api/spaces/{id}/membership — the caller's own sort position. +export const SpaceMembershipSchema = z.object({ + space_id: z.string().default(""), + sort_order: z.number().default(0), +}).loose(); + +export const SpaceMemberSchema = z.object({ + user_id: z.string(), + name: z.string().default(""), + email: z.string().default(""), + avatar_url: z.string().nullable().default(null), + role: z.string().default("member"), + created_at: z.string().default(""), +}).loose(); + +export const ListSpaceMembersResponseSchema = z.object({ + members: z.array(SpaceMemberSchema).default([]), + total: z.number().default(0), +}); + +export const EMPTY_LIST_SPACE_MEMBERS_RESPONSE: ListSpaceMembersResponse = { + members: [], + total: 0, +}; + +export const ListSpacesResponseSchema = z.object({ + spaces: z.array(SpaceSchema).default([]), + total: z.number().default(0), +}).loose(); + +export const EMPTY_SPACE: Space = { + id: "", + workspace_id: "", + name: "", + key: "", + description: "", + icon: null, + issue_counter: 0, + archived_at: null, + created_by: null, + created_at: "", + updated_at: "", + is_member: false, + sort_order: 0, +}; + +export const EMPTY_LIST_SPACES_RESPONSE: ListSpacesResponse = { + spaces: [], + total: 0, +}; + const ProjectSchema = z.object({ id: z.string(), workspace_id: z.string(), @@ -332,6 +405,9 @@ const ProjectSchema = z.object({ issue_count: z.number().default(0), done_count: z.number().default(0), resource_count: z.number().default(0), + // Tolerate a missing key or an explicit null (older backends) by defaulting + // to an empty membership list. + space_ids: z.array(z.string()).nullish().transform((ids) => ids ?? []), }).loose(); const SearchProjectResultSchema = ProjectSchema.extend({ @@ -879,6 +955,7 @@ const AutopilotListItemSchema = z.object({ title: z.string(), description: z.string().nullable().optional(), project_id: z.string().nullable().optional(), + space_id: z.string().nullable().optional(), // Older servers (pre-MUL-2429) omit assignee_type; "agent" is the // documented default. assignee_type: z.string().default("agent"), diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index a4123beb96..82d954bf68 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -16,6 +16,7 @@ export interface IssueSortParam { date_field?: ListIssuesParams["date_field"]; date_start?: ListIssuesParams["date_start"]; date_end?: ListIssuesParams["date_end"]; + space_id?: ListIssuesParams["space_id"]; } export const issueKeys = { @@ -119,6 +120,7 @@ export type MyIssuesFilter = Pick< | "assignee_ids" | "assignee_types" | "creator_id" + | "space_id" | "project_id" | "involves_user_id" >; diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index b4c4f0b561..4a422c6f4c 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -74,6 +74,7 @@ export interface IssueViewState { assigneeFilters: ActorFilterValue[]; includeNoAssignee: boolean; creatorFilters: ActorFilterValue[]; + spaceFilter: string | null; projectFilters: string[]; includeNoProject: boolean; labelFilters: string[]; @@ -112,6 +113,7 @@ export interface IssueViewState { toggleAssigneeFilter: (value: ActorFilterValue) => void; toggleNoAssignee: () => void; toggleCreatorFilter: (value: ActorFilterValue) => void; + setSpaceFilter: (spaceId: string | null) => void; toggleProjectFilter: (projectId: string) => void; toggleNoProject: () => void; toggleLabelFilter: (labelId: string) => void; @@ -140,6 +142,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue assigneeFilters: [], includeNoAssignee: false, creatorFilters: [], + spaceFilter: null, projectFilters: [], includeNoProject: false, labelFilters: [], @@ -210,6 +213,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue : [...state.creatorFilters, value], }; }), + setSpaceFilter: (spaceId) => set({ spaceFilter: spaceId }), toggleProjectFilter: (projectId) => set((state) => ({ projectFilters: state.projectFilters.includes(projectId) @@ -250,6 +254,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue assigneeFilters: [], includeNoAssignee: false, creatorFilters: [], + spaceFilter: null, projectFilters: [], includeNoProject: false, labelFilters: [], diff --git a/packages/core/issues/surface/membership.test.ts b/packages/core/issues/surface/membership.test.ts index e707475798..d487fa52a2 100644 --- a/packages/core/issues/surface/membership.test.ts +++ b/packages/core/issues/surface/membership.test.ts @@ -83,6 +83,18 @@ describe("issueMatchesListFilter", () => { ).toBe(false); }); + it("judges space filters — a moved issue leaves the old space's list", () => { + expect( + issueMatchesListFilter(makeIssue({ space_id: "t1" }), "space:t1", { space_id: "t1" }), + ).toBe(true); + expect( + issueMatchesListFilter(makeIssue({ space_id: "t2" }), "space:t1", { space_id: "t1" }), + ).toBe(false); + expect( + issueMatchesListFilter(makeIssue({ space_id: undefined }), "space:t1", { space_id: "t1" }), + ).toBe("unknown"); + }); + it("never decides involves_user_id — the ownership graph is server-side", () => { expect( issueMatchesListFilter(makeIssue(), "agents", { involves_user_id: "me" }), @@ -110,11 +122,13 @@ describe("issueChangedDims", () => { assignee: true, project: false, status: false, + space: false, }); expect(issueChangedDims({ project_id: null })).toEqual({ assignee: false, project: true, status: false, + space: false, }); }); @@ -124,10 +138,13 @@ describe("issueChangedDims", () => { assignee: false, project: false, status: false, + space: false, }); expect(issueChangedDims({ status: "todo" }, base).status).toBe(false); expect(issueChangedDims({ status: "done" }, base).status).toBe(true); expect(issueChangedDims({ project_id: "p2" }, base).project).toBe(true); + expect(issueChangedDims({ space_id: "t2" }, makeIssue({ space_id: "t1" })).space).toBe(true); + expect(issueChangedDims({ space_id: "t1" }, makeIssue({ space_id: "t1" })).space).toBe(false); }); it("ignores non-membership fields", () => { @@ -135,12 +152,13 @@ describe("issueChangedDims", () => { assignee: false, project: false, status: false, + space: false, }); }); }); describe("listFilterDependsOn", () => { - const none = { assignee: false, project: false, status: false }; + const none = { assignee: false, project: false, status: false, space: false }; it("my:all reacts to assignee changes only", () => { expect(listFilterDependsOn("all", {}, { ...none, assignee: true })).toBe(true); @@ -175,19 +193,28 @@ describe("listFilterDependsOn", () => { ).toBe(false); }); + it("space filters react to space changes", () => { + expect( + listFilterDependsOn("space:t1", { space_id: "t1" }, { ...none, space: true }), + ).toBe(true); + expect( + listFilterDependsOn("space:t1", { space_id: "t1" }, { ...none, status: true }), + ).toBe(false); + }); + it("creator filters never react — creator is immutable", () => { expect( listFilterDependsOn( "created", { creator_id: "me" }, - { assignee: true, project: true, status: true }, + { assignee: true, project: true, status: true, space: true }, ), ).toBe(false); }); it("the unfiltered workspace list never reacts", () => { expect( - listFilterDependsOn(undefined, {}, { assignee: true, project: true, status: true }), + listFilterDependsOn(undefined, {}, { assignee: true, project: true, status: true, space: true }), ).toBe(false); }); }); diff --git a/packages/core/issues/surface/membership.ts b/packages/core/issues/surface/membership.ts index 019cc20f56..be86e7ef16 100644 --- a/packages/core/issues/surface/membership.ts +++ b/packages/core/issues/surface/membership.ts @@ -22,6 +22,7 @@ export interface IssueChangedDims { assignee: boolean; project: boolean; status: boolean; + space: boolean; } /** @@ -44,6 +45,7 @@ export function issueChangedDims( (has("assignee_type") && (!base || base.assignee_type !== p.assignee_type)), project: has("project_id") && (!base || base.project_id !== p.project_id), status: has("status") && p.status !== undefined && (!base || base.status !== p.status), + space: has("space_id") && (!base || base.space_id !== p.space_id), }; } @@ -72,6 +74,7 @@ export function listFilterDependsOn( return true; } if (changed.project && filter.project_id !== undefined) return true; + if (changed.space && filter.space_id !== undefined) return true; // creator_id filters never react to updates — creator is immutable. return false; } @@ -123,6 +126,10 @@ export function issueMatchesListFilter( if (issue.project_id === undefined) unknown = true; else if (issue.project_id !== filter.project_id) return false; } + if (filter.space_id !== undefined) { + if (issue.space_id === undefined) unknown = true; + else if (issue.space_id !== filter.space_id) return false; + } if (filter.involves_user_id !== undefined) { // Indirect-assignee predicate (owned agents / squads) — server-only. unknown = true; diff --git a/packages/core/issues/surface/query-plan.ts b/packages/core/issues/surface/query-plan.ts index 5ea411b412..1bace1b5cb 100644 --- a/packages/core/issues/surface/query-plan.ts +++ b/packages/core/issues/surface/query-plan.ts @@ -3,11 +3,7 @@ import type { AssigneeGroupedIssuesFilter, MyIssuesFilter, } from "../queries"; -import { - issueScopeKey, - UnsupportedIssueScopeError, - type IssueScope, -} from "./scope"; +import { issueScopeKey, type IssueScope } from "./scope"; export type IssueSurfaceQueryPlan = | { @@ -163,7 +159,22 @@ export function buildIssueSurfaceQueryPlan( : {}, }; } - case "team": - throw new UnsupportedIssueScopeError(scope, "issue surface query plan"); + case "space": { + // Mirrors the project plan: space_id is a real server filter on both + // the list and grouped endpoints, so the space surface gets its own + // cache entry, and issues created from it default into the space. + const queryFilter = { space_id: scope.spaceId }; + return { + kind: "scoped", + scopeKey, + queryScope: scopeKey, + queryFilter, + groupedScopeFilter: queryFilter, + loadMoreScope: scopeKey, + loadMoreFilter: queryFilter, + userId: undefined, + createDefaults: { space_id: scope.spaceId }, + }; + } } } diff --git a/packages/core/issues/surface/scope.test.ts b/packages/core/issues/surface/scope.test.ts index 7b08477be4..2890d797f0 100644 --- a/packages/core/issues/surface/scope.test.ts +++ b/packages/core/issues/surface/scope.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - UnsupportedIssueScopeError, - issueScopeKey, -} from "./scope"; +import { issueScopeKey } from "./scope"; import { buildIssueSurfaceQueryPlan } from "./query-plan"; describe("issue surface scope", () => { @@ -25,7 +22,7 @@ describe("issue surface scope", () => { relation: "created", }), ).toBe("actor:agent:a1:created"); - expect(issueScopeKey({ type: "team", teamId: "t1" })).toBe("team:t1"); + expect(issueScopeKey({ type: "space", spaceId: "t1" })).toBe("space:t1"); }); it("builds the workspace query plan", () => { @@ -145,10 +142,16 @@ describe("issue surface scope", () => { }); }); - it("throws for team until the issue API has a team filter", () => { - const scope = { type: "team" as const, teamId: "t1" }; - expect(() => buildIssueSurfaceQueryPlan(scope)).toThrow( - UnsupportedIssueScopeError, - ); + it("builds the space query plan mirroring the project shape", () => { + expect( + buildIssueSurfaceQueryPlan({ type: "space", spaceId: "t1" }), + ).toMatchObject({ + kind: "scoped", + scopeKey: "space:t1", + queryScope: "space:t1", + queryFilter: { space_id: "t1" }, + groupedScopeFilter: { space_id: "t1" }, + createDefaults: { space_id: "t1" }, + }); }); }); diff --git a/packages/core/issues/surface/scope.ts b/packages/core/issues/surface/scope.ts index 21a7839795..552c1de18c 100644 --- a/packages/core/issues/surface/scope.ts +++ b/packages/core/issues/surface/scope.ts @@ -16,7 +16,7 @@ export type IssueScope = actorId: string; relation: "assigned" | "created"; } - | { type: "team"; teamId: string }; + | { type: "space"; spaceId: string }; export class UnsupportedIssueScopeError extends Error { constructor(scope: IssueScope, operation: string) { @@ -35,7 +35,7 @@ export function issueScopeKey(scope: IssueScope): string { return `project:${scope.projectId}`; case "actor": return `actor:${scope.actorType}:${scope.actorId}:${scope.relation}`; - case "team": - return `team:${scope.teamId}`; + case "space": + return `space:${scope.spaceId}`; } } diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts index 004e1a613d..cda525a6b9 100644 --- a/packages/core/issues/ws-updaters.ts +++ b/packages/core/issues/ws-updaters.ts @@ -54,6 +54,7 @@ export function onIssueUpdated( assigneeChanged?: boolean; statusChanged?: boolean; projectChanged?: boolean; + spaceChanged?: boolean; } = {}, ) { // Look up the OLD parent + cached entity before mutating cache state, so we @@ -96,6 +97,11 @@ export function onIssueUpdated( (cachedIssue !== undefined && issue.status !== undefined && issue.status !== cachedIssue.status), + space: + meta.spaceChanged ?? + (cachedIssue !== undefined && + issue.space_id !== undefined && + issue.space_id !== cachedIssue.space_id), }; // The coordinator applies the same rules table the local mutations use: diff --git a/packages/core/layout/sidebar-store.ts b/packages/core/layout/sidebar-store.ts new file mode 100644 index 0000000000..5a90a8e72b --- /dev/null +++ b/packages/core/layout/sidebar-store.ts @@ -0,0 +1,32 @@ +"use client"; + +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage"; +import { defaultStorage } from "../platform/storage"; + +// Sidebar group collapse state, persisted per workspace (same +// workspace-aware storage the quick-create prefs use). Keys are group ids: +// "pinned", "workspace", "more", "spaces", and `space:{spaceId}` for each space +// group. Absent key = expanded — groups default open so a fresh workspace +// shows its whole structure. +interface SidebarState { + collapsed: Record; + setGroupCollapsed: (key: string, collapsed: boolean) => void; +} + +export const useSidebarStore = create()( + persist( + (set) => ({ + collapsed: {}, + setGroupCollapsed: (key, collapsed) => + set((s) => ({ collapsed: { ...s.collapsed, [key]: collapsed } })), + }), + { + name: "multica_sidebar", + storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)), + }, + ), +); + +registerForWorkspaceRehydration(() => useSidebarStore.persist.rehydrate()); diff --git a/packages/core/package.json b/packages/core/package.json index 714e7aca68..2e5644a9f0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -70,6 +70,12 @@ "./projects/queries": "./projects/queries.ts", "./projects/mutations": "./projects/mutations.ts", "./projects/config": "./projects/config.ts", + "./spaces": "./spaces/index.ts", + "./spaces/queries": "./spaces/queries.ts", + "./spaces/mutations": "./spaces/mutations.ts", + "./spaces/default-space": "./spaces/default-space.ts", + "./spaces/last-space-store": "./spaces/last-space-store.ts", + "./layout/sidebar-store": "./layout/sidebar-store.ts", "./labels": "./labels/index.ts", "./labels/queries": "./labels/queries.ts", "./labels/mutations": "./labels/mutations.ts", diff --git a/packages/core/paths/consistency.test.ts b/packages/core/paths/consistency.test.ts index 422b47df50..d67508eada 100644 --- a/packages/core/paths/consistency.test.ts +++ b/packages/core/paths/consistency.test.ts @@ -19,6 +19,7 @@ describe("paths.workspace() shape", () => { "root", "usage", "issues", + "spaceNew", "projects", "autopilots", "agents", diff --git a/packages/core/paths/paths.test.ts b/packages/core/paths/paths.test.ts index 5df542a301..79fc1566d4 100644 --- a/packages/core/paths/paths.test.ts +++ b/packages/core/paths/paths.test.ts @@ -7,7 +7,7 @@ describe("paths.workspace(slug)", () => { it("builds workspace paths with slug prefix", () => { expect(ws.usage()).toBe("/acme/usage"); expect(ws.issues()).toBe("/acme/issues"); - expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123"); + expect(ws.issueDetail("abc-123")).toBe("/acme/issue/abc-123"); expect(ws.projects()).toBe("/acme/projects"); expect(ws.projectDetail("p1")).toBe("/acme/projects/p1"); expect(ws.autopilots()).toBe("/acme/autopilots"); @@ -26,7 +26,7 @@ describe("paths.workspace(slug)", () => { }); it("URL-encodes special characters in ids", () => { - expect(ws.issueDetail("id with space")).toBe("/acme/issues/id%20with%20space"); + expect(ws.issueDetail("id with space")).toBe("/acme/issue/id%20with%20space"); }); }); diff --git a/packages/core/paths/paths.ts b/packages/core/paths/paths.ts index 8743c9e375..c36c5f6ab0 100644 --- a/packages/core/paths/paths.ts +++ b/packages/core/paths/paths.ts @@ -20,7 +20,21 @@ function workspaceScoped(slug: string) { root: () => `${ws}/issues`, usage: () => `${ws}/usage`, issues: () => `${ws}/issues`, - issueDetail: (id: string) => `${ws}/issues/${encode(id)}`, + // Issue detail is identifier-first (Linear-style /issue/NAI-3): the space + // rides in the identifier, never in a nested path segment, so moving an + // issue between spaces can't orphan the URL (old identifiers keep + // resolving via the server-side alias). The same route accepts a UUID — + // internal navigation passes ids, shared links pass identifiers. + issueDetail: (idOrIdentifier: string) => `${ws}/issue/${encode(idOrIdentifier)}`, + // Space-scoped surfaces, addressed by space key (readable, stable: keys + // freeze once a space has issues) — /space/ENG/issues, Linear-style. + spaceIssues: (key: string) => `${ws}/space/${encode(key)}/issues`, + spaceProjects: (key: string) => `${ws}/space/${encode(key)}/projects`, + spaceAutopilots: (key: string) => `${ws}/space/${encode(key)}/autopilots`, + spaceDetail: (key: string) => `${ws}/space/${encode(key)}`, + // Static sibling of spaceDetail — "new" is a reserved space key + // (RESERVED_SPACE_KEYS) precisely so it can never collide with this route. + spaceNew: () => `${ws}/space/new`, projects: () => `${ws}/projects`, projectDetail: (id: string) => `${ws}/projects/${encode(id)}`, autopilots: () => `${ws}/autopilots`, diff --git a/packages/core/paths/reserved-slugs.ts b/packages/core/paths/reserved-slugs.ts index efafda502b..44b64076ed 100644 --- a/packages/core/paths/reserved-slugs.ts +++ b/packages/core/paths/reserved-slugs.ts @@ -11,7 +11,7 @@ * * Convention for new global routes (CLAUDE.md): use a single word * (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Hyphenated - * root-level word groups (`/new-workspace`, `/create-team`) collide with + * root-level word groups (`/new-workspace`, `/create-space`) collide with * common user workspace names — see PR for full discussion. */ export const RESERVED_SLUGS: ReadonlySet = new Set([ @@ -76,7 +76,7 @@ export const RESERVED_SLUGS: ReadonlySet = new Set([ // Reserving each segment name prevents `/{slug}/{view}` from being visually // ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two // things). `workspaces` covers the global `/workspaces/new` workspace-creation - // page; `teams` is reserved for future team management. + // page; `spaces` is reserved for future space management. "issues", "projects", "autopilots", @@ -89,7 +89,8 @@ export const RESERVED_SLUGS: ReadonlySet = new Set([ "skills", "settings", "workspaces", - "teams", + "spaces", + "space", // API / integration prefixes // `api` above already covers `/api/*`; these guard against future top-level diff --git a/packages/core/permissions/index.ts b/packages/core/permissions/index.ts index 4091d8ed1a..03745bf297 100644 --- a/packages/core/permissions/index.ts +++ b/packages/core/permissions/index.ts @@ -18,3 +18,5 @@ export { useAgentPermissions, useSkillPermissions, } from "./use-resource-permissions"; + +export { useCurrentMember } from "./use-current-member"; diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 3f6a3733f1..25e767fc96 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -11,6 +11,7 @@ import { defaultStorage } from "../platform/storage"; import { getCurrentWsId, getCurrentSlug } from "../platform/workspace-storage"; import { issueKeys } from "../issues/queries"; import { projectKeys } from "../projects/queries"; +import { spaceKeys } from "../spaces/queries"; import { pinKeys } from "../pins/queries"; import { autopilotKeys } from "../autopilots/queries"; import { runtimeKeys } from "../runtimes/queries"; @@ -196,6 +197,22 @@ export function applyWorkspaceUpdatedToCache( qc: QueryClient, payload: WorkspaceUpdatedPayload, ): void { + // Space create/update/archive rides the same `workspace:updated` channel but + // carries a `space` instead of a `workspace`. The global query client uses + // staleTime: Infinity, so without an explicit invalidation other clients + // never refetch the space list. Archiving a space also shifts issue + // identifiers/filters, so mirror the space mutations' onSettled issue-key + // invalidation here. + const space = payload.space; + if (space?.workspace_id) { + qc.invalidateQueries({ queryKey: spaceKeys.list(space.workspace_id) }); + qc.invalidateQueries({ queryKey: issueKeys.all(space.workspace_id) }); + // Mirror useArchiveSpace.onSettled: projects carry space_ids, autopilots + // carry space_id, and both group under spaces in the sidebar — an archive + // or rename on another client must refresh them too. + qc.invalidateQueries({ queryKey: projectKeys.all(space.workspace_id) }); + qc.invalidateQueries({ queryKey: autopilotKeys.all(space.workspace_id) }); + } const next = payload.workspace; if (next?.id) { const cached = @@ -637,6 +654,7 @@ export function useRealtimeSync( assigneeChanged: payload.assignee_changed, statusChanged: payload.status_changed, projectChanged: payload.project_changed, + spaceChanged: payload.space_changed, }); if (issue.status) { onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status); diff --git a/packages/core/spaces/default-space.test.ts b/packages/core/spaces/default-space.test.ts new file mode 100644 index 0000000000..7347de54b2 --- /dev/null +++ b/packages/core/spaces/default-space.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { Space } from "../types"; +import { creationDefaultSpaceId, resolveCreationSpaceId } from "./default-space"; + +function space(overrides: Partial): Space { + return { + id: "space-id", + workspace_id: "ws-1", + name: "Space", + key: "SPC", + description: "", + icon: null, + issue_counter: 0, + archived_at: null, + created_by: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + is_member: false, + sort_order: 0, + ...overrides, + }; +} + +describe("creationDefaultSpaceId", () => { + it("prefers the first space in the user's personal sort order", () => { + const spaces = [ + space({ id: "a", is_member: true, sort_order: 2 }), + space({ id: "b", is_member: true, sort_order: 1 }), + space({ id: "c", is_member: false, created_at: "2020-01-01T00:00:00Z" }), + ]; + expect(creationDefaultSpaceId(spaces)).toBe("b"); + }); + + it("falls back to the earliest-created active space when the user has no membership rows", () => { + const spaces = [ + space({ id: "newer", is_member: false, created_at: "2026-02-01T00:00:00Z" }), + space({ id: "older", is_member: false, created_at: "2026-01-01T00:00:00Z" }), + ]; + expect(creationDefaultSpaceId(spaces)).toBe("older"); + }); + + it("ignores archived spaces in both the personal and earliest-created fallbacks", () => { + const spaces = [ + space({ id: "archived-mine", is_member: true, sort_order: 0, archived_at: "2026-01-01T00:00:00Z" }), + space({ id: "archived-oldest", created_at: "2020-01-01T00:00:00Z", archived_at: "2026-01-01T00:00:00Z" }), + space({ id: "active", created_at: "2026-01-02T00:00:00Z" }), + ]; + expect(creationDefaultSpaceId(spaces)).toBe("active"); + }); + + it("returns undefined when there are no active spaces", () => { + expect(creationDefaultSpaceId([])).toBeUndefined(); + }); +}); + +describe("resolveCreationSpaceId", () => { + const spaces = [space({ id: "fallback", created_at: "2026-01-01T00:00:00Z" })]; + + it("prioritizes structural context over the creation default", () => { + expect( + resolveCreationSpaceId(spaces, { + parentSpaceId: "parent", + projectSpaceId: "project", + lastSpaceId: "last", + }), + ).toBe("parent"); + expect(resolveCreationSpaceId(spaces, { projectSpaceId: "project", lastSpaceId: "last" })).toBe( + "project", + ); + expect(resolveCreationSpaceId(spaces, { lastSpaceId: "last" })).toBe("last"); + }); + + it("falls through to the creation default when no context applies", () => { + expect(resolveCreationSpaceId(spaces, {})).toBe("fallback"); + }); +}); diff --git a/packages/core/spaces/default-space.ts b/packages/core/spaces/default-space.ts new file mode 100644 index 0000000000..9d06db0eb0 --- /dev/null +++ b/packages/core/spaces/default-space.ts @@ -0,0 +1,47 @@ +import type { Space } from "../types"; + +/** + * The space a new issue defaults into when no other context (route, parent, + * single-space project) applies: the first space in the user's personal order. + * Falls back to the workspace's earliest-created active space for users whose + * membership rows predate the membership rollout — no space is flagged + * "default"; the earliest one is the same one the server resolves to when a + * create request omits space_id (see GetDefaultWorkspaceSpace). + */ +export function creationDefaultSpaceId(spaces: Space[]): string | undefined { + const active = spaces.filter((space) => !space.archived_at); + const mine = active + .filter((space) => space.is_member) + .sort((a, b) => a.sort_order - b.sort_order); + const earliest = [...active].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + )[0]; + return (mine[0] ?? earliest)?.id; +} + +/** + * Single fallback chain for "which space does this new issue/project/autopilot + * belong to", shared by every creation surface. Callers pass whichever + * signals apply to them (e.g. only issue creation has a parent to inherit + * from) and leave the rest undefined. + * + * Priority: structural inheritance (parent issue's space) > single-space + * project inference > the space the user last created something in > + * personal static default (first space in my sort order). + * + * Explicit picks and view/route context (e.g. a space's own Issues page) + * are NOT part of this chain — callers seed their own local `spaceId` state + * from those directly and only fall through to this resolver while that + * state is still unset. + */ +export function resolveCreationSpaceId( + spaces: Space[], + ctx: { parentSpaceId?: string; projectSpaceId?: string; lastSpaceId?: string | null }, +): string | undefined { + return ( + ctx.parentSpaceId ?? + ctx.projectSpaceId ?? + ctx.lastSpaceId ?? + creationDefaultSpaceId(spaces) + ); +} diff --git a/packages/core/spaces/index.ts b/packages/core/spaces/index.ts new file mode 100644 index 0000000000..bccc7a1305 --- /dev/null +++ b/packages/core/spaces/index.ts @@ -0,0 +1,17 @@ +export { + spaceKeys, + spaceListOptions, + activeSpaceListOptions, + mySpaceListOptions, + spaceMembersOptions, + sortSpacesForDisplay, +} from "./queries"; +export { + useCreateSpace, + useUpdateSpace, + useArchiveSpace, + useUpdateSpaceMembership, + useReplaceSpaceMembers, +} from "./mutations"; +export { creationDefaultSpaceId, resolveCreationSpaceId } from "./default-space"; +export { useLastSpaceStore } from "./last-space-store"; diff --git a/packages/core/spaces/last-space-store.ts b/packages/core/spaces/last-space-store.ts new file mode 100644 index 0000000000..09d88821ca --- /dev/null +++ b/packages/core/spaces/last-space-store.ts @@ -0,0 +1,32 @@ +"use client"; + +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage"; +import { defaultStorage } from "../platform/storage"; + +// Per-workspace memory of the last space the user created something in. +// Shared by every creation surface (issue, quick-create, project, autopilot) +// — users don't distinguish "last space for issues" from "last space for +// projects", so one memory backs all of them. Only consulted as a fallback +// when no stronger signal applies (view context, parent issue, single-space +// project); see resolveCreationSpaceId in ./default-space. +interface LastSpaceState { + lastSpaceId: string | null; + setLastSpaceId: (id: string | null) => void; +} + +export const useLastSpaceStore = create()( + persist( + (set) => ({ + lastSpaceId: null, + setLastSpaceId: (id) => set({ lastSpaceId: id }), + }), + { + name: "multica_last_space", + storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)), + }, + ), +); + +registerForWorkspaceRehydration(() => useLastSpaceStore.persist.rehydrate()); diff --git a/packages/core/spaces/mutations.ts b/packages/core/spaces/mutations.ts new file mode 100644 index 0000000000..38c34925f8 --- /dev/null +++ b/packages/core/spaces/mutations.ts @@ -0,0 +1,135 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { useWorkspaceId } from "../hooks"; +import { issueKeys } from "../issues/queries"; +import { projectKeys } from "../projects/queries"; +import { autopilotKeys } from "../autopilots/queries"; +import { spaceKeys } from "./queries"; +import type { CreateSpaceRequest, ListSpacesResponse, UpdateSpaceRequest } from "../types"; + +export function useCreateSpace() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (data: CreateSpaceRequest) => api.createSpace(data), + // Create stays onSuccess-append: there's no stable local id to seed an + // optimistic row, so we wait for the server-assigned space. + onSuccess: (space) => { + qc.setQueryData(spaceKeys.list(wsId), (old) => + old && !old.spaces.some((t) => t.id === space.id) + ? { ...old, spaces: [...old.spaces, space], total: old.total + 1 } + : old, + ); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: spaceKeys.all(wsId) }); + }, + }); +} + +export function useUpdateSpace() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string } & UpdateSpaceRequest) => + api.updateSpace(id, data), + onMutate: async ({ id, ...data }) => { + await qc.cancelQueries({ queryKey: spaceKeys.list(wsId) }); + const prevList = qc.getQueryData(spaceKeys.list(wsId)); + qc.setQueryData(spaceKeys.list(wsId), (old) => + old + ? { ...old, spaces: old.spaces.map((t) => (t.id === id ? { ...t, ...data } : t)) } + : old, + ); + return { prevList }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prevList) qc.setQueryData(spaceKeys.list(wsId), ctx.prevList); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: spaceKeys.all(wsId) }); + // A key change re-derives every issue identifier under the space. + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + }, + }); +} + +export function useUpdateSpaceMembership() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ id, sort_order }: { id: string; sort_order: number }) => + api.updateSpaceMembership(id, { sort_order }), + // Optimistic: sidebar ordering must not snap back while the PATCH is in + // flight. Fractional sort keys mean only the dragged space's row changes. + onMutate: async ({ id, sort_order }) => { + await qc.cancelQueries({ queryKey: spaceKeys.list(wsId) }); + const prevList = qc.getQueryData(spaceKeys.list(wsId)); + qc.setQueryData(spaceKeys.list(wsId), (old) => + old + ? { + ...old, + spaces: old.spaces.map((t) => (t.id === id ? { ...t, sort_order } : t)), + } + : old, + ); + return { prevList }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prevList) qc.setQueryData(spaceKeys.list(wsId), ctx.prevList); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: spaceKeys.all(wsId) }); + }, + }); +} + +export function useReplaceSpaceMembers() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ id, member_ids }: { id: string; member_ids: string[] }) => + api.replaceSpaceMembers(id, member_ids), + // No optimistic patch: the config panel closes on save, and the + // caller's own is_member may flip (they can add/remove themselves), so + // one settled invalidation of the list + members caches is the simplest + // correct reconcile. + onSettled: (_data, _err, { id }) => { + qc.invalidateQueries({ queryKey: spaceKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: spaceKeys.members(wsId, id) }); + }, + }); +} + +export function useArchiveSpace() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (id: string) => api.archiveSpace(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: spaceKeys.list(wsId) }); + const prevList = qc.getQueryData(spaceKeys.list(wsId)); + const archivedAt = new Date().toISOString(); + qc.setQueryData(spaceKeys.list(wsId), (old) => + old + ? { + ...old, + spaces: old.spaces.map((t) => + t.id === id ? { ...t, archived_at: archivedAt } : t, + ), + } + : old, + ); + return { prevList }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prevList) qc.setQueryData(spaceKeys.list(wsId), ctx.prevList); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: spaceKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: projectKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) }); + }, + }); +} diff --git a/packages/core/spaces/queries.ts b/packages/core/spaces/queries.ts new file mode 100644 index 0000000000..46436cfd37 --- /dev/null +++ b/packages/core/spaces/queries.ts @@ -0,0 +1,67 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; +import type { Space } from "../types"; + +// Canonical presentation order for space lists shown to a user: joined spaces +// first in the personal drag order (mirroring the sidebar's Spaces section), +// then non-joined spaces alphabetically. Pickers and filters share this so +// every space list a user sees is ordered the same way. +export function sortSpacesForDisplay(spaces: Space[]): Space[] { + const mine = spaces + .filter((space) => space.is_member) + .sort((a, b) => a.sort_order - b.sort_order); + const others = spaces + .filter((space) => !space.is_member) + .sort((a, b) => a.name.localeCompare(b.name)); + return [...mine, ...others]; +} + +export const spaceKeys = { + all: (wsId: string) => ["spaces", wsId] as const, + list: (wsId: string) => [...spaceKeys.all(wsId), "list"] as const, + members: (wsId: string, spaceId: string) => + [...spaceKeys.all(wsId), "members", spaceId] as const, +}; + +export function spaceListOptions(wsId: string) { + return queryOptions({ + queryKey: spaceKeys.list(wsId), + queryFn: () => api.listSpaces(), + select: (data) => data.spaces, + }); +} + +export function activeSpaceListOptions(wsId: string) { + // Shares spaceListOptions' query key (and fetch/cache) — the active/archived + // distinction is a per-observer `select`, not a separate cache entry, so + // mutation cache patches on the base key are reflected here too. Sorted + // with sortSpacesForDisplay so pickers match the sidebar's personal order. + return queryOptions({ + queryKey: spaceKeys.list(wsId), + queryFn: () => api.listSpaces(), + select: (data) => + sortSpacesForDisplay(data.spaces.filter((space) => !space.archived_at)), + }); +} + +export function spaceMembersOptions(wsId: string, spaceId: string) { + return queryOptions({ + queryKey: spaceKeys.members(wsId, spaceId), + queryFn: () => api.listSpaceMembers(spaceId), + select: (data) => data.members, + }); +} + +export function mySpaceListOptions(wsId: string) { + // The sidebar's Spaces section: only spaces the user joined, in their + // personal order. Same cache entry as spaceListOptions (per-observer + // select), so reorder patches on the base key reflect here instantly. + return queryOptions({ + queryKey: spaceKeys.list(wsId), + queryFn: () => api.listSpaces(), + select: (data) => + data.spaces + .filter((space) => space.is_member && !space.archived_at) + .sort((a, b) => a.sort_order - b.sort_order), + }); +} diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 001c75e218..54834c573a 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -11,6 +11,7 @@ export interface CreateIssueRequest { assignee_type?: IssueAssigneeType; assignee_id?: string; parent_issue_id?: string; + space_id?: string; project_id?: string; /** Ordered stage (>= 1) grouping this sub-issue under its parent. */ stage?: number; @@ -31,6 +32,10 @@ export interface UpdateIssueRequest { due_date?: string | null; parent_issue_id?: string | null; project_id?: string | null; + /** Move the issue to another space. Numbers are per-space, so the move + * renumbers the issue server-side (the old identifier keeps resolving + * via an alias); never null — every issue belongs to exactly one space. */ + space_id?: string; /** Ordered stage (>= 1); null clears it (unstaged). */ stage?: number | null; /** Attachment IDs to bind to this issue alongside the description update. @@ -88,6 +93,7 @@ export interface ListIssuesParams { */ assignee_types?: IssueAssigneeType[]; creator_id?: string; + space_id?: string; project_id?: string; /** * Widen the assignee filter to issues where the user is the *indirect* @@ -131,6 +137,7 @@ export interface ListGroupedIssuesParams { assignee_id?: string; assignee_ids?: string[]; creator_id?: string; + space_id?: string; project_id?: string; /** See `ListIssuesParams.involves_user_id` — same semantics. */ involves_user_id?: string; diff --git a/packages/core/types/autopilot.ts b/packages/core/types/autopilot.ts index 0d52bdbb47..67a140142f 100644 --- a/packages/core/types/autopilot.ts +++ b/packages/core/types/autopilot.ts @@ -29,6 +29,7 @@ export interface Autopilot { title: string; description: string | null; project_id?: string | null; + space_id?: string | null; assignee_type: AutopilotAssigneeType; assignee_id: string; status: AutopilotStatus; @@ -136,6 +137,7 @@ export interface CreateAutopilotRequest { title: string; description?: string; project_id?: string | null; + space_id?: string | null; // Optional on the wire — when omitted the server defaults to "agent" so // older clients keep working. assignee_type?: AutopilotAssigneeType; @@ -149,6 +151,7 @@ export interface UpdateAutopilotRequest { title?: string; description?: string | null; project_id?: string | null; + space_id?: string | null; // Send `assignee_type` together with `assignee_id` whenever you change the // assignee — the server requires both for a type swap. assignee_type?: AutopilotAssigneeType; diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index 34a05e6452..458ed8dc1b 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -5,6 +5,7 @@ import type { Comment, Reaction } from "./comment"; import type { TimelineEntry } from "./activity"; import type { Workspace, MemberWithUser, Invitation } from "./workspace"; import type { Project } from "./project"; +import type { Space } from "./space"; import type { Label } from "./label"; // WebSocket event types (matching Go server protocol/events.go) @@ -106,6 +107,10 @@ export interface IssueUpdatedPayload { assignee_changed?: boolean; status_changed?: boolean; project_changed?: boolean; + // Moving an issue between spaces renumbers it and moves it across + // space-filtered lists; the flag drives the same membership reconcile as + // project_changed. + space_changed?: boolean; } export interface IssueDeletedPayload { @@ -184,7 +189,10 @@ export interface CommentUnresolvedPayload { } export interface WorkspaceUpdatedPayload { - workspace: Workspace; + workspace?: Workspace; + // Space create/update/archive is broadcast on the same channel; the payload + // carries the affected space instead of the workspace in that case. + space?: Space; } export interface WorkspaceDeletedPayload { diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index ea56f8c08c..8c482b8970 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -64,6 +64,7 @@ export type { } from "./agent"; export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent"; export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace"; +export type { Space, CreateSpaceRequest, UpdateSpaceRequest, ListSpacesResponse, SpaceMembership, SpaceMember, ListSpaceMembersResponse } from "./space"; export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox"; export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference"; export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment"; diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts index adc857aa5f..c922c17a89 100644 --- a/packages/core/types/issue.ts +++ b/packages/core/types/issue.ts @@ -35,6 +35,9 @@ export type IssueMetadata = Record; export interface Issue { id: string; workspace_id: string; + space_id?: string | null; + space_key?: string | null; + space_name?: string | null; number: number; identifier: string; title: string; diff --git a/packages/core/types/project.ts b/packages/core/types/project.ts index 333d094826..0be8f704b3 100644 --- a/packages/core/types/project.ts +++ b/packages/core/types/project.ts @@ -17,6 +17,7 @@ export interface Project { issue_count: number; done_count: number; resource_count: number; + space_ids?: string[]; } export interface CreateProjectRequest { @@ -27,6 +28,7 @@ export interface CreateProjectRequest { priority?: ProjectPriority; lead_type?: "member" | "agent"; lead_id?: string; + space_ids?: string[]; // Resources to attach in the same transaction as the project. Server returns // 4xx (and rolls back) if any one is invalid or duplicate. resources?: CreateProjectResourceRequest[]; @@ -40,6 +42,7 @@ export interface UpdateProjectRequest { priority?: ProjectPriority; lead_type?: "member" | "agent" | null; lead_id?: string | null; + space_ids?: string[]; } export interface ListProjectsResponse { diff --git a/packages/core/types/space.ts b/packages/core/types/space.ts new file mode 100644 index 0000000000..a5b32b6e8c --- /dev/null +++ b/packages/core/types/space.ts @@ -0,0 +1,60 @@ +export interface Space { + id: string; + workspace_id: string; + name: string; + key: string; + description: string; + icon: string | null; + issue_counter: number; + archived_at: string | null; + created_by: string | null; + created_at: string; + updated_at: string; + /** Requesting user's membership view — the sidebar shows only joined + * spaces, ordered by sort_order (per-user fractional position). */ + is_member: boolean; + sort_order: number; +} + +export interface CreateSpaceRequest { + name: string; + key: string; + description?: string; + icon?: string | null; + /** Workspace members invited alongside the creator (who joins as lead). */ + member_ids?: string[]; +} + +export interface UpdateSpaceRequest { + name?: string; + key?: string; + description?: string; + icon?: string | null; +} + +export interface ListSpacesResponse { + spaces: Space[]; + total: number; +} + +/** Caller's own membership row, as returned by PATCH /api/spaces/{id}/membership. */ +export interface SpaceMembership { + space_id: string; + sort_order: number; +} + +/** A space member with user display data (GET /api/spaces/{id}/members). */ +export interface SpaceMember { + user_id: string; + name: string; + email: string; + avatar_url: string | null; + /** "lead" | "member" — informational in v1, no privileges attached. */ + role: string; + created_at: string; +} + +export interface ListSpaceMembersResponse { + members: SpaceMember[]; + total: number; +} diff --git a/packages/core/workspace/index.ts b/packages/core/workspace/index.ts index 4e9f5df488..ecd9138ebd 100644 --- a/packages/core/workspace/index.ts +++ b/packages/core/workspace/index.ts @@ -1,3 +1,4 @@ export * from "./queries"; export * from "./mutations"; export * from "./hooks"; +export * from "./space-key"; diff --git a/packages/core/workspace/mutations.ts b/packages/core/workspace/mutations.ts index 84487d4572..501b8e7812 100644 --- a/packages/core/workspace/mutations.ts +++ b/packages/core/workspace/mutations.ts @@ -12,7 +12,12 @@ import { export function useCreateWorkspace() { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: { name: string; slug: string; description?: string }) => + mutationFn: (data: { + name: string; + slug: string; + description?: string; + context?: string; + }) => api.createWorkspace(data), // Seed the workspace list cache BEFORE callers navigate to /{newWs.slug}/issues. // The destination [workspaceSlug]/layout queries by slug from this cache; diff --git a/packages/core/workspace/space-key.ts b/packages/core/workspace/space-key.ts new file mode 100644 index 0000000000..d17aad9c49 --- /dev/null +++ b/packages/core/workspace/space-key.ts @@ -0,0 +1,25 @@ +export const SPACE_KEY_REGEX = /^[A-Z][A-Z0-9]{0,6}$/; + +// Mirrors reservedSpaceKeys in server/internal/handler/workspace.go: keys +// that would collide with a static route under /space/{key} (e.g. the +// create-space page at /space/new). +export const RESERVED_SPACE_KEYS = new Set(["NEW"]); + +// Mirrors the server-side normalizeSpaceKey (handler/workspace.go) and the +// migration backfill: uppercase, strip characters outside [A-Z0-9], truncate +// to 7, and prefix digit-leading keys with "T". +export function normalizeSpaceKey(value: string): string { + let key = value + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]/g, "") + .slice(0, 7); + if (/^[0-9]/.test(key)) { + key = `T${key}`.slice(0, 7); + } + return key; +} + +export function isValidSpaceKey(key: string): boolean { + return SPACE_KEY_REGEX.test(key) && !RESERVED_SPACE_KEYS.has(key); +} diff --git a/packages/ui/components/common/plain-text-field.tsx b/packages/ui/components/common/plain-text-field.tsx new file mode 100644 index 0000000000..5f43e381ed --- /dev/null +++ b/packages/ui/components/common/plain-text-field.tsx @@ -0,0 +1,72 @@ +"use client"; + +import * as React from "react"; +import { cn } from "../../lib/utils"; +import { Textarea } from "../ui/textarea"; + +interface PlainTextFieldProps { + defaultValue: string; + /** Called on blur when the trimmed value changed and is within the limit. */ + onCommit: (value: string) => void; + placeholder?: string; + /** Soft limit: typing past it is allowed but shows the hint and blocks commit. */ + maxLength?: number; + /** Over-limit hint line; caller supplies the localized text. */ + limitHint?: (count: number, max: number) => string; + className?: string; + id?: string; + "aria-label"?: string; +} + +/** + * Not an editor — a borderless plain-text area that reads as page text until + * clicked (Linear's "Add a description…" pattern). Single line at rest, + * auto-grows with content. Commits on blur, restores on Escape. Re-seed by + * changing the React `key` when the upstream value changes. + */ +function PlainTextField({ + defaultValue, + onCommit, + placeholder, + maxLength = 255, + limitHint, + className, + ...props +}: PlainTextFieldProps) { + const [value, setValue] = React.useState(defaultValue); + const over = value.length > maxLength; + + return ( +
+