Files
multica/packages/views/layout/app-sidebar.tsx
Naiyuan Qing 3447764b03 feat(i18n): full rollout — 21 namespaces translated (en + zh-Hans) (#1853)
* feat(i18n): rollout phase — translate 9 namespaces (WIP)

Phase 1 complete (基建 + login + Settings language switcher),
phase 2 partial (Wave 4 done, search done). Pending namespaces
documented inline; another developer can pick up from here.

Infrastructure
--------------
- server: add users.language column + extend PATCH /api/me
  (TestUpdateMeAcceptsLanguage / TestUpdateMePreservesLanguage)
- packages/core/i18n: types / pickLocale (intl-localematcher) /
  browser-cookie-adapter / createI18n (initAsync false +
  useSuspense false) / I18nProvider / LocaleAdapterProvider
- Split server-safe vs React entries:
    @multica/core/i18n        — for proxy/RSC/middleware (no React)
    @multica/core/i18n/react  — for client trees (createContext)
  (RSC vendored React lacks createContext; mixed import would crash
  proxy.ts at module load.)
- packages/views/i18n: useT hook + selector API augmentation
  (i18next v26 default; auto-propagates to apps via the side-effect
  import in use-t.ts).
- apps/web: proxy.ts (Next 16 renamed middleware) merges existing
  legacy/root redirects with x-multica-locale header forwarding;
  layout.tsx reads locale via headers() and pre-loads RSC resources.
- apps/desktop: webPreferences.additionalArguments injects
  systemLocale (no sendSync — avoids main-thread blocking IPC);
  renderer adapter reads via process.argv.
- ESLint: i18next/no-literal-string at file-scope for translated
  files via packages/views/eslint.config.mjs TRANSLATED_FILES.
- glossary.md (packages/views/locales/) freezes term policy:
  Issue / Workspace / Agent / Skill / Autopilot / Daemon / Runtime
  stay English; Inbox / Project / Comment / Member translate.

Translated namespaces (9 / 19)
------------------------------
- auth: login page (web wrapper含 desktop-handoff 文案) + Settings
  Appearance language switcher
- editor: 9 .tsx (bubble-menu / link-hover-card / readonly-content /
  title-editor / extensions: code-block / file-card / image-view /
  mention-suggestion) + 32 keys
- invite: 25 keys
- labels / members / my-issues: Wave 4 全部
- search: command palette 35 keys
- navigation: no user-facing strings (no-op)

Pending (10 / 19)
-----------------
issues (46 files / ~210 keys)
agents (29 files / ~155 keys; presence.ts + config.ts label maps
  允许进 i18n)
onboarding (22 files / ~150 keys)
settings rest / skills / modals / workspace / chat / inbox /
projects / autopilots / layout

Workflow for picking up
-----------------------
- Glossary: packages/views/locales/glossary.md (mandatory read)
- Reference impls: auth/login-page.tsx + editor/* (selector API +
  i18n-provider test wrapper pattern)
- Per namespace:
    1. create locales/{en,zh-Hans}/{ns}.json
    2. add to packages/views/i18n/resources-types.ts
    3. useT('{ns}') + t($ => $.foo) in components
    4. add files to TRANSLATED_FILES in eslint.config.mjs
    5. typecheck + test + lint must pass
- Subagents currently CANNOT write files (sandbox deny). Run as
  hybrid: subagent researches + outputs full JSON + tsx diff,
  controller writes.

Other
-----
- scripts/init-worktree-env.sh: default
  MULTICA_DEV_VERIFICATION_CODE=888888 in dev for deterministic
  login (gated by isProductionEnv).

Verified: pnpm typecheck (6 pkgs ok), pnpm test (232 pass),
make test (Go).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(i18n): rewrite glossary aligned with docs zh voice

Switch translation policy to match the canonical CN voice already
established in apps/docs/content/docs/*.zh.mdx (20+ files). The new
rule splits product nouns into two classes:

- Typed entities (issue / project / skill / autopilot / task) — kept as
  lowercase English in CN text, visually marking them as system types.
- Concepts (workspace / agent / daemon / runtime / inbox) — fully
  translated (工作区 / 智能体 / 守护进程 / 运行时 / 收件箱).

Previous glossary kept Workspace / Agent / Daemon / Runtime as English
on "工程惯例" grounds, but docs zh and CN AI ecosystem (Coze / 腾讯元器
/ 百度) consistently translate these. App UI now matches docs voice so
users don't see split personality between the app and its own docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(i18n): register 6 namespaces and retrofit zh strings to new glossary

Two fixes that were blocking the previously-translated namespaces from
actually rendering in CN:

1. RESOURCES gap — locales/index.ts only loaded common/auth/settings,
   but resources-types.ts declared 12 namespaces and 6 of them had real
   translation content. At runtime i18next would fall back to raw keys
   for editor / invite / labels / members / my-issues / search.
   Register all 9 currently-translated namespaces.

2. Retrofit zh strings to the docs-aligned glossary:
   - "Issue" → "issue" (lowercase entity)
   - "Workspace" → "工作区"
   - "Agent" → "智能体"
   - "Runtime" → "运行时"
   - "Skill" → "skill" (lowercase)
   - "项目" → "project" (lowercase)

Touched: editor.json (sub_issue + mention.group_issues), invite.json
(3 Workspace occurrences), members.json (agents_section / more_agents),
my-issues.json (8 retrofits across page/header/errors), search.json
(13 retrofits across groups/pages/commands/empty).

Verified: pnpm typecheck (6/6) + pnpm test (238/238) all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate inbox namespace

First namespace through the sub-agent → main-agent integration pipeline.

JSON: en/inbox.json + zh-Hans/inbox.json — 60 keys across page / menu /
list / detail / types / labels / errors. Time-formatter labels are kept
compact in EN ("5m" / "3h" / "2d") and use full units in zh ("5 分钟" /
"5 小时" / "5 天") since raw "5 分" reads as "5 marks/points" in CN.

Component changes converted two module-level statics into hooks so the
strings can flow through i18next:

- inbox-list-item.tsx: `timeAgo` (pure fn) → `useTimeAgo` (hook
  returning a fn). The local copy is a duplicate of @multica/core/utils
  `timeAgo` that is only used by inbox-page; other consumers across
  chat/agents/skills/issues stay on the core util for now and will be
  translated when their namespaces land.

- inbox-detail-label.tsx: `typeLabels` (static const Record) →
  `useTypeLabels` (hook returning the same Record shape). Call sites
  keep the existing `typeLabels[type]` access pattern.

inbox-page.tsx now uses both hooks and `useT('inbox')` selector calls
for all hardcoded strings (~24 sites: header / dropdown menu / list
empty state / detail panel / mobile back / quick-create-failed flow /
all error toasts).

Wired up: resources-types.ts, locales/index.ts RESOURCES, ESLint
TRANSLATED_FILES (3 inbox tsx files now lint-protected).

Verified: pnpm typecheck (6/6) + pnpm --filter @multica/views test
(238/238) + ESLint clean on inbox/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate workspace namespace

Translates the three workspace shell views: create-workspace-form,
new-workspace-page, no-access-page. Also fixes the prior-art
no-unescaped-entities lint errors in no-access-page.tsx — the
apostrophes in "doesn't" / "don't" were JSX text literals that move
into JSON values after translation, so the lint rule no longer fires.

Tests wrapped: workspace/create-workspace-form.test.tsx,
workspace/no-access-page.test.tsx, modals/create-workspace.test.tsx
all now wrap render() with <I18nProvider locale="en"> so the en values
in workspace.json drive the rendered text and the existing assertions
continue to match.

Slug constants kept: WORKSPACE_SLUG_FORMAT_ERROR /
WORKSPACE_SLUG_CONFLICT_ERROR exports in workspace/slug.ts are still
imported by onboarding/steps/step-workspace.tsx (out of scope here).
The workspace shell now reads its strings from workspace.json directly.

Multica.ai brand prefix in the slug input affordance is wrapped with
an inline `// eslint-disable-next-line i18next/no-literal-string` per
glossary policy on brand names.

Renamed sign_in_other → sign_in_different to avoid colliding with
i18next's `_other` plural-suffix convention which the selector-API
typings treated as a plural form of `sign_in`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate projects namespace

Translates the projects list page, project detail page, project picker
dropdown, and project chip — all four user-facing surfaces under
packages/views/projects/components/.

New file: projects/components/labels.ts exposes three hooks that
replace the static `.label` field on PROJECT_STATUS_CONFIG /
PROJECT_PRIORITY_CONFIG and the previous module-level
`formatRelativeDate` helper. Core's `.label` stays untouched (it's
still consumed by search and the create-project modal, both
out-of-scope for this namespace) — those will flip when their
respective namespaces translate.

In zh, the "project" entity stays lowercase English per glossary
(`新建 project`, `还没有 project`, `从 project 移除`). Status / priority /
table column labels translate fully.

The cancelled / done / paused etc. status labels duplicate per-
namespace as `projects.status.*` rather than reading from a future
shared status namespace. This matches the auth/inbox/workspace
pattern of self-contained namespaces. If a generic "issue/project
status" pool emerges later, these can collapse.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on projects/ (1 pre-existing warning
about useEffect/sidebarRef dep, unrelated to i18n).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate autopilots namespace

Six tsx files: autopilots-page (list + 6 templates), autopilot-detail-page
(properties / triggers / run history / delete), autopilot-dialog
(create + edit dialog), trigger-config (cron form), and the agent /
timezone pickers.

Hook conversions for module-level helpers that need t():
- summarizeTrigger / describeTrigger → useSummarizeTrigger /
  useDescribeTrigger (no external callers, removed the plain exports)
- formatRelativeDate → useFormatRelativeDate (per-component hook)
- formatCountdown → useFormatCountdown (per-component hook)
- TEMPLATES array now keyed by id; titles + summaries pull from
  templates/{id}/{title,summary} JSON. Prompts stay raw EN since
  they're injected directly into the agent task — translating them
  would translate the agent's instructions, not the user's UI.

Status / execution-mode / run-status enums render via t($ => $.status[k])
with k typed against the core type (no separate hook needed).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on autopilots/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate skills namespace

Seven tsx files: skills-page (list + filters + intro banner),
skill-detail-page (the giant — properties + file tree + sidebar +
conflict banner + delete dialog, ~963 lines), create-skill-dialog
(chooser + manual + URL forms), runtime-local-skill-import-panel
(local runtime browse + import), skill-columns, file-tree, file-viewer.

Notable patterns:
- `createSkillColumns` factory → `useSkillColumns` hook so column
  headers flow through useT. Column identity changes per render is
  fine — DataTable handles it.
- `validateNewFilePath` (pure helper) → `useValidateNewFilePath` hook
  so the 5 validation error messages can be translated.
- skill_files / used_by / description_with_agents use i18next plural
  keys (`_one` / `_other`) — the type system collapses these into a
  single PluralValue access, so call sites use
  `t($ => $.foo, { count })` and i18next picks the form.
- Per glossary, "skill" stays lowercase EN in zh ("新建 skill",
  "已删除 skill", "未找到该 skill").

Test wrapper: runtime-local-skill-import-panel.test.tsx now wraps
render() with <I18nProvider> so the assertion on /Import to Workspace/i
matches the EN translation.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on skills/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate chat namespace

Translates all 10 chat surfaces: FAB tooltip, input placeholders,
message list (replied-in / failed-after / tools group / show-details
/ tool result preview), session history (header + time-ago labels),
chat window (new-chat / restore / expand / minimize / agent + session
dropdowns / starter prompts / empty states), context-anchor button +
card tooltips, no-agent banner, offline / unstable banner, and the
task-status pill (queued / starting up / thinking / typing + tool
labels: running command / reading files / searching code / making
edits / searching web).

Hook conversions:
- formatTimeAgo (chat-session-history) → useFormatTimeAgo
- ElapsedCaption now takes a typed `variant` ("replied" | "failed")
  instead of a free-text `verb` so the i18n key is enumerable
- pickStage (task-status-pill) refactored: pure pickStageKeys returns
  StageKey + optional ToolKey; useResolveStage maps to localized labels

Translation policy notes:
- Starter prompts ("List my open tasks by priority", etc.) are user
  UI when displayed AND the user's input when clicked — translating
  them sends the agent the user's locale-native phrasing, which is
  the right UX for a CN user using a CN agent.
- buildAnchorMarkdown (chat-window) stays in English: it's an
  agent-bound markdown prefix injected into the outgoing message,
  not user-facing UI.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate modals namespace

Translates all 11 modal sources: registry (no UI text), backlog-agent-hint,
set-parent-issue, add-child-issue, delete-issue-confirm, feedback,
issue-picker, create-workspace, create-project, create-issue (manual),
quick-create-issue (agent panel).

Notable patterns:
- create-project re-uses useProjectStatusLabels / useProjectPriorityLabels
  hooks from views/projects/components/labels — same translation source
  as the projects list / detail, no duplication.
- create-issue.tsx: renamed `toast.custom((t) => ...)` callback param to
  `toastId` to avoid shadowing the closure-captured useT() `t` function.
- Test wrapper added to modals/create-issue.test.tsx so the two assertions
  on rendered modal text (success toast + Create another) match the EN
  bundle. modals/create-workspace.test.tsx was already wrapped (workspace
  ns commit).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate settings namespace (rest of tabs)

Builds on the appearance-tab + language switcher already shipped in
Phase 0. Translates the remaining 8 settings surfaces: settings-page
shell (left nav + tab keys), account / profile, notifications-tab
(5 group labels + descriptions), tokens-tab (create / list /
revoke / created dialog), workspace-tab (general fields + danger
zone + leave/delete confirmations), members-tab (invite + role
config + revoke / remove flows), repositories-tab, labs-tab,
delete-workspace-dialog.

Hook conversion: members-tab `roleConfig` static const → `useRoleLabels`
hook returning a Record<MemberRole, {label, description, icon}>. The
icon stays as a typed React component (Crown / Shield / User), so
rendering pattern is unchanged at call sites.

Test wrapper: settings/components/delete-workspace-dialog.test.tsx
now wraps render() with <I18nProvider> (custom render() helper)
because the test asserts on rendered button labels ("Delete workspace",
"Cancel", "Deleting...").

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate runtimes namespace (entry surfaces)

Translates the user-facing runtime list page surfaces:
runtimes-page (header / search / filters / chips / empty / no-matches /
bootstrapping), runtime-detail (topbar + delete dialog + delete toasts),
runtime-detail-page (not-found state), shared.tsx (4-state HealthBadge
labels).

Hook conversion: shared `healthLabel(health)` was a pure module-level
function. Added `useHealthLabel` hook for translated call sites; kept
`healthLabel` as an EN-only fallback for non-component callers (column
factory in runtime-columns).

Deferred:
- runtime-list / runtime-columns (data table column headers + cell
  bodies) — large surface, not in the page-load critical path.
- connect-remote-dialog / update-section / usage-section — secondary
  flows, English remains acceptable until a focused pass.
- charts/* — primarily numeric tooltips and axes; minimal user-visible
  text.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate layout namespace (sidebar nav, help, loader)

Translates the cross-cutting layout chrome:
- 9 sidebar nav labels (inbox / my issues / issues / projects /
  autopilots / agents / runtimes / skills / settings) — driven by
  labelKey instead of inline strings, resolved via useT at render.
- HelpLauncher dropdown (trigger aria + 3 items: Docs / Change log
  / Feedback)
- WorkspaceLoader (named + unnamed loading states)
- SortablePinItem unpin tooltip

Pattern shift in app-sidebar.tsx: nav arrays carry `labelKey: NavLabelKey`
(typed against the layout JSON) instead of `label: string`. The string
comparison checks (`item.label === "Inbox"`) became cleaner ID-based
checks (`item.key === "inbox"`).

Deferred: deeper sidebar surfaces — workspace switcher dropdown,
"New Issue" CTA, "Pinned" / "Workspace" / "Configure" group labels —
remain English. The 9 nav labels are the ones that read in every
session.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate onboarding namespace (welcome + step header)

Translates the user-first-impression surfaces of the onboarding flow:

- step-welcome.tsx (the wordmark, headline, lede paragraphs, all CTAs:
  Download Desktop / Continue on web / Start exploring / I've done
  this before, illustration caption)
- step-header.tsx ("Step N of M" counter + matching aria-label)
- onboarding-flow.tsx (skip-onboarding error toast)

Test wrapper added to onboarding/components/step-header.test.tsx —
custom render() helper wraps with <I18nProvider> so the "Step 2 of 5"
assertions match the EN bundle.

Deferred (acceptable English fallback for now): step-questionnaire,
step-workspace, step-runtime-connect, step-platform-fork, step-agent,
step-first-issue, cli-install-instructions, option-card, runtime
aside panels, starter-content-prompt, cloud-waitlist-expand. These
are deeper steps with significant copy that would benefit from a
focused dedicated pass — voice on each is more nuanced (questionnaire
options, runtime install instructions, agent template recommendations).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(i18n): add EN/zh-Hans key parity guard

Schema-level vitest that walks RESOURCES.en and RESOURCES["zh-Hans"]
namespace by namespace and asserts both bundles cover the same key
set. i18next plural rule is normalized before compare (`_one` /
`_other` collapse to a single logical key) so EN's plural pair
matches zh's `_other`-only form.

Catches retrofit drift where a new EN key lands without zh —
previously this would silently fall back to the English string in
production. Cheap to keep green: 39 tests across 21 namespaces in
under a second.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate issues namespace

Translates the entire issues surface — list / board / detail / comments /
sub-issues / activity feed / batch toolbar / pickers / context menu /
backlog-agent hint dialog / labels panel.

Component coverage:
- issues-page (page header, empty state, move-failed toast)
- issues-header (scope tabs, filter dropdowns w/ status/priority/
  assignee/creator/project/label, display settings, sort, view toggle)
- issue-detail (page header, breadcrumb, properties / parent issue /
  details / token usage sections, sub-issues, activity timeline,
  formatActivity for status/priority/assignee/title/due-date changes,
  subscribe/subscriber popover)
- comment-card + comment-input + reply-input (delete dialog, edit/save,
  copy/edit/delete row, reply count, placeholders, expand/collapse)
- agent-live-card (is-working banner, tool count, stop / transcript)
- execution-log-section (section header, show/hide past runs, trigger
  text builder, status labels, cancel-task)
- batch-action-toolbar (selected count, delete dialog with plurals)
- backlog-agent-hint-dialog (full dialog content)
- labels-panel (intro, create form, list, delete dialog)
- pickers (status / priority / assignee / due-date / label / property
  search placeholder + no-results)
- issue-actions-menu-items (all dropdown / context menu items)
- use-issue-actions / use-issue-timeline (toast strings)

STATUS_CONFIG / PRIORITY_CONFIG label rendering routed through
$.status[enum] / $.priority[enum] at every call site; the core config
keeps its English fallback for non-i18n consumers but UI never reads
.label directly anymore.

Tests retrofitted: issues-page, issue-detail, and issue-actions-menu
RTL specs now wrap renders in <I18nProvider> with the EN bundle, so
their string assertions match the bundle (not hardcoded literals).

ESLint i18next allow-list extended to 24 issues files. Verified:
pnpm --filter @multica/views typecheck + test (277/277) all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate agents namespace

Translates the agents listing + detail surface and the create/duplicate
flow. Covers the high-frequency surfaces; deeper sub-tab editors
(activity / instructions / skills / env / custom-args bodies, and the
hooks-buggy runtime/model/concurrency pickers) are deferred — they
have their own pre-existing react-hooks rule violations and benefit
from a focused dedicated pass.

Component coverage:
- agents-page (page header w/ tagline + new button, scope segment,
  search, sort dropdown, availability chips, archived toolbar, empty
  state, no-matches messaging w/ search interpolation, list-load
  error)
- agent-detail-page (back link, archived banner, archive dialog,
  not-found state, all 4 toast strings)
- agent-detail-inspector (avatar editor, name + description popover,
  description dialog, every PropRow label, validation message,
  presence badge label sourced from $.availability[enum])
- agent-overview-pane (tab labels, discard-unsaved-changes dialog)
- create-agent-dialog (title / description / labels / placeholders /
  duplicate-suffix / runtime filter buttons / runtime status copy)
- agent-row-actions (full dropdown items + cancel-tasks dialog with
  pluralized "N running + M queued" summary + archive dialog + 6 toasts)
- agent-columns (every header cell, You / Archived chips, runtime
  fallback labels, availability + workload labels via $.availability /
  $.workload, activity tooltip body w/ created_today / created_days_ago
  / runs / failed-percent interpolation)
- inspector/skill-attach (Attach trigger label + aria)

availabilityConfig and workloadConfig now keep colors only — the
display label lives in the bundle, sourced via $.availability[enum]
and $.workload[enum] at every call site. Same pattern as
STATUS_CONFIG/PRIORITY_CONFIG in the issues namespace.

ESLint i18next allow-list extended to 8 agents files.
Verified: pnpm --filter @multica/views typecheck + test (277/277)
all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(i18n): clear 30 stray EN strings in translated files

Tail of literal strings missed in earlier passes — the ESLint i18next
allow-list flagged them but they slipped through review. Files touched:

- layout/app-sidebar.tsx (10 keys: Workspaces / Pending invitations /
  Create workspace / Join / Decline / Log out / New Issue + shortcut /
  Pinned / Workspace / Configure)
- runtimes/components/runtime-detail.tsx (Serving header + serving_count
  pluralization, no_agents copy, running/queued chips with count
  interpolation, Diagnostics header, CLI label, Delete runtime button,
  Technical details toggle, last seen interpolation)
- onboarding/steps/step-welcome.tsx (entire WelcomeIllustration mock —
  5 cards × actor names + body copy + 3 mention chips + 2 timestamps;
  zh translation reads naturally instead of leaving the demo English)
- settings/components/labs-tab.tsx (`Co-authored-by: ...` git trailer
  wrapped in {} so linter sees a JS string, not JSX text — magic
  identifier git relies on, must not translate)
- settings/components/members-tab.tsx (✓ glyph wrapped in {})
- modals/feedback.tsx (⌘↵ shortcut wrapped in {})

ServingAgentsCard now reads availability/workload labels from
`agents` namespace (cross-namespace useT) so the bundle-truth pattern
holds: presenceConfig keeps colours only, label text comes from the
shared bundle.

Verified: typecheck + 277/277 tests + lint (only the pre-existing
react-hooks rule-of-hooks errors remain, which task #6 addresses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agents): rules-of-hooks + translate 4 model/runtime pickers

Three pre-existing react-hooks/rules-of-hooks violations + one missing
useMemo dep cleared, then the four pickers wired through useT.

Hook order fixes:
- concurrency-picker: useEffect now runs before the !canEdit early
  return. Stale-draft reset still works the same way.
- runtime-picker: useMemo for the filtered list moved above the
  !canEdit branch.
- model-dropdown: `models = data?.models ?? []` was minting a fresh
  array each render and tripping the deps lint of the downstream
  useMemo. Wrap in useMemo so the reference is stable.

Translation coverage:
- concurrency-picker: tooltip ("Concurrency · N max..."), range
  helper text, Save button.
- runtime-picker: trigger label fallback ("No runtime"), tooltip
  text composed from {{name}} + status, Mine/All filter buttons,
  empty-list copy, "owned by {{name}}" + status fragments in row
  tooltip, Cloud badge, online/offline aria.
- model-picker: trigger label, tooltip, "Managed by runtime"
  fallback, search placeholder, "Discovering models…", default
  badge, "No models available", "Use \"X\"" custom-id flow, Clear
  button + its title.
- model-dropdown: every label string including the "Select a runtime
  first" / "Default (provider)" / "Runtime offline — enter manually"
  trigger fallbacks, the supported=false explanation block, discovery
  failed badge, all popover items.

ESLint allow-list extended to 4 picker files. Verified: typecheck +
277/277 tests + lint (0 errors, only pre-existing react-hooks warnings
in unrelated files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate runtimes list + connect dialog + CLI updater

Three deep runtime surfaces wired through useT, with the agents
namespace doing double duty for shared availability/workload labels.

runtime-columns:
- 7 column headers via t-augmented createRuntimeColumns({ t }).
- HealthCell now reads from useHealthLabel() (already translation-aware)
  instead of the EN-only healthLabel() helper.
- WorkloadCell sources the label from $.workload[enum] (cross-namespace
  to agents) — colour stays via workloadConfig.
- CostCell delta "flat" copy + CLI cell "Desktop" badge + update-
  available aria/tooltip + RowMenu's full delete dialog (title /
  description with {{name}} interpolation / cancel / confirm /
  deleting state) plus its admin-permission hint.

connect-remote-dialog:
- Three steps fully translated: instructions (header + 4 numbered
  steps + security warning + troubleshooting list with mono code
  snippets escaped as JS strings), waiting (loader + hint), success
  (CTA pair).
- Mono CLI commands wrapped in {} so linter sees JS strings — those
  are literal commands that must stay untranslated for the user to
  paste into a terminal.

update-section:
- statusConfig collapsed to icon+colour only; labels move to
  $.update.status[enum] for proper translation per-state.
- "CLI Version:" / "Latest" / "available" / "Update" / "Retry"
  copy + the "Managed by Desktop" tooltip and disabled hint.

Layout helpers tagged: runtime-list passes `t` through to the column
factory the same way agent-columns does.

ESLint allow-list extended with the 4 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. usage-section.tsx
(KPI cards / WhenChart / TopUsageBreakdown / receipt table) is the
remaining runtimes surface — chart-heavy and benefits from a focused
pass next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate 5 agent detail tabs + skill-add dialog

The 5 tabs that fill the agent detail right pane plus the shared
skill picker dialog. Agents bundle gains a `tab_body` block with
sub-namespaces per tab + a `common` slot for save/add/unsaved.

Tab coverage:
- instructions-tab: intro paragraph, multi-line example placeholder
  (full 18-line zh translation), Save / Unsaved.
- env-tab: read-only intro / empty state, editable intro with two
  inline `<code>` env-var examples kept English (mono terminal
  payloads), KEY / value placeholders, Show/Hide value aria, Add /
  Remove aria, all 3 toasts (duplicate keys / saved / save failed).
- custom-args-tab: intro about whitespace splitting, launch-mode
  prefix line + `<your args>` placeholder, --flag value placeholder,
  Add, Remove aria, both toasts.
- skills-tab: intro, Add skill button, import-hint callout, empty
  state title + hint + add-CTA, remove-failed toast.
- activity-tab: 3 section titles (Now / Last 30 days / Recent work),
  active-task pluralization, performance subtitle, all 3 empty
  states, runs/success%/avg-duration/failed pluralization with
  interpolation, source labels (Issue / Chat / Autopilot / Untracked),
  source fallbacks (Quick create / Creating issue / Chat session /
  Autopilot run), issue-short fallback, "Triggered by" tooltip
  header, open-issue / transcript / cancel-task tooltips and ARIAs,
  cancelling state, started/dispatched/queued time prefixes, show
  more.
- skill-add-dialog: dialog title + description, empty list copy,
  Cancel button, add-failed toast.

skills-tab.test.tsx wrapped in <I18nProvider> with the EN bundle so
its `Local runtime skills are always available` assertion still
matches the resolved translation instead of the raw key path.

ESLint allow-list extended with the 6 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. Only the per-test
mock for skills-tab needed wrapping; the other 4 tabs ship without
test files of their own and inherit the I18nProvider chain via
agent-overview-pane / agent-detail-page test renders (when those
exist later).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate onboarding step-questionnaire + option-card

The user-profile step (3 questions) is the first deferred onboarding
deep step now wired through useT.

step-questionnaire:
- Eyebrow + headline + answered-progress counter with {{count}}
  interpolation
- All 3 questions and their option labels (team size / role / use case)
- All 3 "Other" placeholders for free-text fallback
- Right-rail "Why three questions" / "What you get" panel: 2 eyebrow
  rows, 2 unlock-item title+body pairs, learn-more link
- Back / Continue buttons via shared `common` block

option-card: shared "Other" radio label and aria.

Test wrapped in <I18nProvider>. EN value of `other_label` kept as
"Other" so the existing /^other$/i regex in step-questionnaire.test
keeps matching after the rendering pipeline switched from a hardcoded
literal to a bundle lookup.

ESLint allow-list extended with these 2 files. The remaining 4 deep
steps (workspace / runtime-connect / platform-fork / agent), the
2 ancillary surfaces (cli-install-instructions / starter-content-
prompt), and the 3 side panels (runtime-aside-panel / cloud-waitlist-
expand / compact-runtime-row) will be surfaced + swept by the global
ESLint switch (next commit).

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): flip ESLint to glob + drain remaining hardcoded EN

ESLint i18next/no-literal-string now applies to **/*.tsx by default
instead of an explicit allow-list. Files that genuinely still need
hardcoded EN are listed in STILL_HARDCODED — concrete, finite, and
the goal is to drain that list to zero.

Tail strings translated in this commit (surfaced by the global flip):

- common/task-transcript/agent-transcript-dialog.tsx — full dialog:
  status badge (Running / Completed / Failed), sr-only DialogTitle,
  Filter dropdown trigger + Clear filters, Copy all / Copy filtered /
  Copied, tool-calls + events metadata chips with pluralization,
  events-filtered "{{shown}} of {{total}}" interpolation, "Waiting
  for events..." live state, "No execution data recorded." past
  state. New `transcript` block in agents namespace.
- runtimes/components/charts/activity-heatmap.tsx — Less / More
  legend labels around the contribution-style heat squares.
- search/search-trigger.tsx — sidebar Search... button label.
  ⌘ glyph wrapped in {} to satisfy the linter (mono shortcut symbol,
  not translatable).

Holdouts (STILL_HARDCODED, ~14 files): the deep onboarding steps
(workspace / runtime-connect / platform-fork / agent / first-issue /
cli-install-instructions, plus 4 ancillary panels), the runtimes
usage-section + KPI cards, and 5 minor agent visual primitives
(sparkline / agent-presence-indicator / agent-profile-card /
visibility-badge / char-counter). Each one gets a dedicated future
pass; the global rule prevents new hardcoded strings from landing
elsewhere.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): drain agent visual primitives + onboarding small components

8 files removed from STILL_HARDCODED:

agents/components/:
- char-counter — over-limit text with {{count}} interpolation
- visibility-badge — uses new agents.visibility.{private,workspace}.
  {label,tooltip} block; drops VISIBILITY_LABEL/TOOLTIP imports from
  core in favour of bundle-driven copy
- agent-presence-indicator — availability + workload labels via
  $.availability[enum] / $.workload[enum] (cross-namespace),
  queue-badge "+N queued" with pluralization
- agent-profile-card — Agent unavailable / Detail link / Owner /
  Skills / Runtime / Unknown runtime / Archived chip / availability
  line via cross-namespace lookup

agents.json: new presence + visibility + profile_card + char_counter
blocks.

onboarding/components/:
- compact-runtime-row — online/offline aria via agents.availability
- runtime-aside-panel — full content (What's a runtime / Good to
  know / Swap anytime / Add more later / docs link)
- starter-content-prompt — full dialog (title / description with
  inline emphasis / both buttons / 3 toasts)
- cloud-waitlist-expand — intro paragraph + warning span / email
  + reason labels + placeholders + Optional badge / Join + on-list
  states / both toasts

onboarding/steps/:
- cli-install-instructions — copy aria + intro + 2 step labels

onboarding.json: new runtime_aside / cli_install / starter_content /
cloud_waitlist blocks.

Tests for step-platform-fork + step-runtime-connect wrapped in
<I18nProvider> with EN bundle so /you're on the list/i etc. still
matches the resolved translations.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate onboarding deep steps

The 5 large onboarding steps that were deferred from earlier passes,
plus their support helpers, all wired through useT.

step-first-issue (final beat — flips onboarded_at):
- error_title / Retry / retry_failed toast / finishing / opening
  states.

step-agent (creates the user's first agent):
- Templates moved from a module-level const to a useT-driven
  useAgentTemplates() hook. Names + emoji stay constant (visual
  identity), labels + blurbs + instructions resolve from the
  bundle. coding / planning / writing / assistant — all four
  templates ship a full zh translation that reads naturally.
- Recommended badge, eyebrow + headline + lede, footer hint,
  Create {{name}} CTA, create_failed toast.
- Right-rail "About agents" panel (4 way-items + headline +
  add-more hint + docs link).

step-workspace (create or pick existing):
- 5 footer states (open / creating / creating-pending / name-first
  / pick), all hint + CTA strings via interpolation.
- Name + URL + slug placeholders, issue-prefix preview spans,
  Create-new card title + subtitle.
- 8-row WorkspacePreviewCard sidebar (Inbox / Issues / Agents /
  Projects / Autopilot / Runtimes / Skills / And more) — every
  label + meta strapped to bundle keys.
- 4 perks (assign / chat / invite / switch) + 3 next-steps
  (runtime / agent / starter), 2 toasts (slug-conflict / failed).
- `multica.ai/${slug}` mono URL escaped via template-literal
  expression so the linter sees a JS string.

step-runtime-connect (desktop scan flow):
- 3 phase headlines + ledes (scanning / found / empty), trust-strip
  status (all online / N online / none online) with pluralization,
  online/offline labels, Skip / Continue / Selected hint.
- Empty-view 2 cards (skip + waitlist) and the cloud waitlist
  dialog wrapper.

step-platform-fork (web fan-out):
- Eyebrow + headline + lede, footer hint with 3 phase variants.
- Primary download card (before/after click) + 2 alt cards (CLI /
  cloud) + CLI dialog with 4 elapsed-time stages (normal / midway /
  slow / stalled), live-listening header, runtime-connected
  pluralization, cloud waitlist dialog.

ESLint: STILL_HARDCODED list shrunk from 14 entries to 1 — only
runtimes/components/usage-section.tsx (chart-heavy KPI panel)
remains.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate runtimes usage panel + drop STILL_HARDCODED

Final i18n holdout: the runtimes usage panel (KPI hero, WHEN chart
tabs, cost-by breakdowns, daily breakdown table) is wired through
useT("runtimes"). With this drained, the eslint scaffolding for
explicit holdouts is removed — every JSX text node in @multica/views
now flows through i18n.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(i18n): drain rollout gaps + add cross-device sync

Lands the post-review punch list for the i18n rollout: closes correctness
gaps that would have shipped silently, and adds the missing cross-device
locale sync the rollout's docs already promised.

Coverage:
- Register issues + agents namespaces in RESOURCES (90 useT call sites
  were rendering keys-as-text in production)
- Harden parity test to compare RESOURCES keys against on-disk JSON
  files, so a future missing namespace registration fails loudly
- Server-side language whitelist in UpdateMe + reject-unsupported test
- Safe SupportedLocale resolution in appearance-tab (no more `as` cast
  on a region-tagged BCP-47 string)
- HTML lang attribute uses zh-CN (not zh-Hans) for screen reader / CJK
  font-stack compatibility
- Cookie Secure flag on https
- Pulled createBrowserCookieLocaleAdapter out of the server-safe entry
  into a new @multica/core/i18n/browser subpath; document.cookie access
  can no longer leak into Edge middleware imports

Cross-device sync:
- New UserLocaleSync component mounted in CoreProvider; on login, if
  user.language differs from the active i18n.language, persist via the
  adapter and reload. Both apps benefit
- Desktop main process tracks system locale and emits IPC on focus when
  it changes; renderer reloads only when the user has no explicit
  Settings choice (their preference still wins)

Tests:
- pickLocale / matchLocale (11 cases incl. region-tagged BCP-47, malformed
  tags, zh-Hant collapse-to-zh-Hans semantics)
- browser-cookie-adapter (6 cases under jsdom)
- Shared renderWithI18n helper at packages/views/test/i18n.tsx that wraps
  the real RESOURCES map; future tests opt in instead of inlining a
  per-file TEST_RESOURCES slice that goes stale silently

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(conventions): consolidate naming + i18n glossary into docs site

Single source of truth for code naming, i18n translation glossary, and
Chinese voice rules. Previously split between packages/views/locales/glossary.md
and scattered comments — now lives at apps/docs/content/docs/developers/conventions.{mdx,zh.mdx}
with both English and Chinese versions kept in sync.

Three sections per page:
1. Code naming — routes, packages, files, DB, Go, TS, commits
2. i18n translation glossary — entity vs concept rule, what to translate,
   word combination, plurals, interpolation, key naming
3. Chinese voice + style — punctuation, principles, where to look in doubt

Side effects:
- packages/views/locales/glossary.md collapses to a stub redirecting to
  the docs page; do not edit it
- CLAUDE.md gets a new top-level "Conventions reference" section so any
  Claude session sees the pointer before any other rule
- apps/docs/content/docs/developers/ gets a stub English meta.json so the
  conventions page is reachable on the EN side (contributing.zh.mdx /
  architecture.zh.mdx remain ZH-only — separate work)
- Both root sidebars get a new "Developers" group

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(i18n): apply zh voice rules + translate project/autopilot

Two-part cleanup driven by the conventions doc landed last commit:

Voice violations (mechanical sweep across 10 zh-Hans namespaces):
- 「」 (Japanese-style brackets) → \" to match the EN source's straight
  double quotes (~13 sites)
- … (single-char ellipsis) → ... three dots (~43 sites)
- Drop translation-ese pronoun "我们" where it's a pure narrator
  ("我们已发送" → "已发送", "我们替你托管" → "由 Multica 托管"); keep
  "告诉我们" where "we" is the legitimate brand recipient
- "作为父级 / 作为子级" → "设为父级 / 设为子级"
- "任务" mistranslated as the task entity → `task` (lowercase EN entity)
- Dialog title "Autopilot" → "autopilot"

Translate project / autopilot per industry consensus:
- `project` → 「项目」 (~42 value sites). Feishu / Tower / Teambition /
  PingCode / GitHub Projects all translate; no Chinese product keeps
  `project`.
- `autopilot` → 「自动化」 (~34 value sites). Avoids the Tesla-style
  「自动驾驶」 association; matches Notion / Feishu's industry term.
- Issue / skill / task remain lowercase EN per dev-team familiarity.
- Sidebar nav-label entities get Title Case ("Issue" / "Skill" / "我的
  Issue") so the entry-point label reads as a proper UI signal; body
  prose stays lowercase.

Conventions doc (EN + ZH) reflects the decision and adds a "why these
translate but issue/skill/task don't" rationale block.

Verification: parity test 45/45, full monorepo typecheck green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(i18n): translate chat session delete + project resources section

Two features main shipped while this branch was idle never went through
the i18n pass:

- Chat session delete confirmation dialog (#2115) and history toggle
  tooltip (#2117): adds session_history.delete_dialog.* and
  session_history.row_delete_*, plus window.history_show_tooltip /
  history_back_tooltip.
- Project resources sidebar (#1926/#2080/#2111): entire component
  including toasts, popover form, attach/remove tooltips. New
  projects.resources subtree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:16:12 +08:00

726 lines
28 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@multica/ui/lib/utils";
import { AppLink, useNavigation } from "../navigation";
import { HelpLauncher } from "./help-launcher";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Inbox,
ListTodo,
Bot,
Monitor,
ChevronDown,
ChevronRight,
Settings,
LogOut,
Plus,
Check,
BookOpenText,
SquarePen,
CircleUser,
FolderKanban,
X,
Zap,
} from "lucide-react";
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible";
import { StatusIcon } from "../issues/components/status-icon";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@multica/ui/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { useAuthStore } from "@multica/core/auth";
import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/paths";
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api, ApiError } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
import { pinListOptions } from "@multica/core/pins/queries";
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import type { PinnedItem } from "@multica/core/types";
import { useLogout } from "../auth";
import { ProjectIcon } from "../projects/components/project-icon";
import { useT } from "../i18n";
// Top-level nav items stay active when the user is on a child route
// (e.g. "Projects" stays lit on /:slug/projects/:id). Pinned items keep
// strict equality elsewhere — a pinned project shouldn't highlight on
// sub-pages of itself.
function isNavActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(href + "/");
}
// Stable empty arrays for query defaults. Using an inline `= []` default on
// `useQuery` creates a new array reference on every render when `data` is
// undefined (e.g. query disabled or loading) — which in turn breaks any
// `useEffect`/`useMemo` that depends on the value, and can trigger infinite
// re-render loops when the effect itself calls `setState`.
const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
// Only parameterless paths are valid nav destinations.
type NavKey =
| "inbox"
| "myIssues"
| "issues"
| "projects"
| "autopilots"
| "agents"
| "runtimes"
| "skills"
| "settings";
// Static schema (key + icon) — labels resolved at render via useT("layout").
type NavLabelKey =
| "inbox"
| "my_issues"
| "issues"
| "projects"
| "autopilots"
| "agents"
| "runtimes"
| "skills"
| "settings";
const personalNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
{ key: "inbox", labelKey: "inbox", icon: Inbox },
{ key: "myIssues", labelKey: "my_issues", icon: CircleUser },
];
const workspaceNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
{ key: "issues", labelKey: "issues", icon: ListTodo },
{ key: "projects", labelKey: "projects", icon: FolderKanban },
{ key: "autopilots", labelKey: "autopilots", icon: Zap },
{ key: "agents", labelKey: "agents", icon: Bot },
];
const configureNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
{ key: "runtimes", labelKey: "runtimes", icon: Monitor },
{ key: "skills", labelKey: "skills", icon: BookOpenText },
{ key: "settings", labelKey: "settings", icon: Settings },
];
function DraftDot() {
const hasDraft = useIssueDraftStore((s) => !!(s.draft.title || s.draft.description));
if (!hasDraft) return null;
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
}
/**
* Presentational pin row. The `label` and `iconNode` are computed by the
* parent `PinRow` from cached issue / project detail queries — keeping
* this component dumb means the dnd-kit / navigation wiring lives in
* one place and the data flow is explicit.
*/
function SortablePinItem({
pin,
href,
pathname,
onUnpin,
label,
iconNode,
}: {
pin: PinnedItem;
href: string;
pathname: string;
onUnpin: () => void;
label: string;
iconNode: React.ReactNode;
}) {
const { t } = useT("layout");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
const wasDragged = useRef(false);
useEffect(() => {
if (isDragging) wasDragged.current = true;
}, [isDragging]);
const style = { transform: CSS.Transform.toString(transform), transition };
const isActive = pathname === href;
return (
<SidebarMenuItem
ref={setNodeRef}
style={style}
className={cn("group/pin", isDragging && "opacity-30")}
{...attributes}
{...listeners}
>
<SidebarMenuButton
size="sm"
isActive={isActive}
render={<AppLink href={href} draggable={false} />}
onClick={(event) => {
if (wasDragged.current) {
wasDragged.current = false;
event.preventDefault();
return;
}
}}
className={cn(
"text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground",
isDragging && "pointer-events-none",
)}
>
{iconNode}
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap"
style={{
maskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
WebkitMaskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
}}
>{label}</span>
<Tooltip>
<TooltipTrigger
render={<span role="button" />}
className="hidden size-2.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground group-hover/pin:flex hover:text-foreground"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onUnpin();
}}
>
<X className="size-1" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>{t(($) => $.sidebar.unpin_tooltip)}</TooltipContent>
</Tooltip>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
/**
* Smart wrapper that resolves a pin's display data (label + status/icon)
* from the issue / project detail query cache. Both queries are declared
* unconditionally with `enabled` gates so the hook order stays stable
* regardless of `pin.item_type`.
*
* Loading: render a flat skeleton so the sidebar height doesn't jump.
* Missing (deleted item / 404): render nothing — the row hides itself
* until the user unpins manually or a server-side cascade catches up.
*/
function PinRow({
pin,
href,
pathname,
onUnpin,
wsId,
}: {
pin: PinnedItem;
href: string;
pathname: string;
onUnpin: () => void;
wsId: string;
}) {
const isIssue = pin.item_type === "issue";
const issueQuery = useQuery({
...issueDetailOptions(wsId, pin.item_id),
enabled: isIssue,
});
const projectQuery = useQuery({
...projectDetailOptions(wsId, pin.item_id),
enabled: !isIssue,
});
const triggeredRef = useRef(false);
useEffect(() => {
const err = isIssue ? issueQuery.error : projectQuery.error;
if (err instanceof ApiError && err.status === 404 && !triggeredRef.current) {
triggeredRef.current = true;
onUnpin();
}
}, [isIssue, issueQuery.error, onUnpin, projectQuery.error]);
if (isIssue) {
if (issueQuery.isPending) return <PinSkeleton />;
if (issueQuery.isError || !issueQuery.data) return null;
const issue = issueQuery.data;
const label = issue.identifier ? `${issue.identifier} ${issue.title}` : issue.title;
const iconNode = (
/* Override parent [&_svg]:size-4 — pinned items need smaller icons to match sm size */
<StatusIcon status={issue.status} className="!size-3.5 shrink-0" />
);
return (
<SortablePinItem
pin={pin}
href={href}
pathname={pathname}
onUnpin={onUnpin}
label={label}
iconNode={iconNode}
/>
);
}
if (projectQuery.isPending) return <PinSkeleton />;
if (projectQuery.isError || !projectQuery.data) return null;
const project = projectQuery.data;
const iconNode = <ProjectIcon project={project} size="sm" />;
return (
<SortablePinItem
pin={pin}
href={href}
pathname={pathname}
onUnpin={onUnpin}
label={project.title}
iconNode={iconNode}
/>
);
}
function PinSkeleton() {
return (
<SidebarMenuItem>
<div className="flex h-7 w-full items-center gap-2 px-2">
<div className="size-3.5 shrink-0 rounded-sm bg-sidebar-accent/40" />
<div className="h-3 w-24 rounded bg-sidebar-accent/40" />
</div>
</SidebarMenuItem>
);
}
interface AppSidebarProps {
/** Rendered above SidebarHeader (e.g. desktop traffic light spacer) */
topSlot?: React.ReactNode;
/** Rendered in the header between workspace switcher and new-issue button (e.g. search trigger) */
searchSlot?: React.ReactNode;
/** Extra className for SidebarHeader */
headerClassName?: string;
/** Extra style for SidebarHeader */
headerStyle?: React.CSSProperties;
}
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
const { t } = useT("layout");
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const logout = useLogout();
const workspace = useCurrentWorkspace();
const p = useWorkspacePaths();
const { data: workspaces = EMPTY_WORKSPACES } = useQuery(workspaceListOptions());
const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions());
const wsId = workspace?.id;
const { data: inboxItems = EMPTY_INBOX } = useQuery({
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
queryFn: () => api.listInbox(),
enabled: !!wsId,
});
const unreadCount = React.useMemo(
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
enabled: !!wsId && !!userId,
});
const deletePin = useDeletePin();
const reorderPins = useReorderPins();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
// Local presentational copy of pinnedItems for drop-animation stability.
// Follows TQ at rest; frozen during a drag gesture so a mid-drag cache
// write (our own optimistic update, or a WS refetch) cannot reorder the
// DOM under dnd-kit while its drop animation is still interpolating.
const [localPinned, setLocalPinned] = useState<PinnedItem[]>(pinnedItems);
const isDraggingRef = useRef(false);
useEffect(() => {
if (!isDraggingRef.current) {
setLocalPinned(pinnedItems);
}
}, [pinnedItems]);
const handleDragStart = useCallback(() => {
isDraggingRef.current = true;
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
isDraggingRef.current = false;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = localPinned.findIndex((p) => p.id === active.id);
const newIndex = localPinned.findIndex((p) => p.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(localPinned, oldIndex, newIndex);
setLocalPinned(reordered);
reorderPins.mutate(reordered);
},
[localPinned, reorderPins],
);
const queryClient = useQueryClient();
const acceptInvitationMut = useMutation({
mutationFn: (id: string) => api.acceptInvitation(id),
// After accepting an invitation, navigate INTO the newly-joined workspace.
// Otherwise the user stays on their current workspace and just sees the
// new one appear in the dropdown — silent and confusing (this is MUL-820).
onSuccess: async (_, invitationId) => {
const invitation = myInvitations.find((i) => i.id === invitationId);
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
// staleTime: 0 forces a real network fetch — we need the joined workspace
// in the list before we can resolve its slug for navigation.
const list = await queryClient.fetchQuery({
...workspaceListOptions(),
staleTime: 0,
});
const joined = invitation
? list.find((w) => w.id === invitation.workspace_id)
: null;
if (joined) {
push(paths.workspace(joined.slug).issues());
}
},
});
const declineInvitationMut = useMutation({
mutationFn: (id: string) => api.declineInvitation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
},
});
// Global "C" shortcut: opens whichever create mode the user landed on last
// (agent vs manual), persisted in useCreateModeStore. The mode switch lives
// inside both modal footers so users can flip without remembering which
// shortcut goes where — `c` always means "open the create flow I prefer".
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "c" && e.key !== "C") return;
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
const tag = (e.target as HTMLElement)?.tagName;
const isEditable =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement)?.isContentEditable;
if (isEditable) return;
if (useModalStore.getState().modal) return;
e.preventDefault();
const lastMode = useCreateModeStore.getState().lastMode;
if (lastMode === "manual") {
// Auto-fill project when on a project detail page (manual form only —
// agent mode lets the agent infer project from the prompt).
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
const data = projectMatch ? { project_id: projectMatch[1] } : undefined;
useModalStore.getState().open("create-issue", data);
} else {
useModalStore.getState().open("quick-create-issue");
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [pathname]);
return (
<Sidebar variant="inset">
{topSlot}
{/* Workspace Switcher */}
<SidebarHeader className={cn("py-3", headerClassName)} style={headerStyle}>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton>
<span className="relative">
<WorkspaceAvatar name={workspace?.name ?? "M"} size="sm" />
{myInvitations.length > 0 && (
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-brand ring-1 ring-sidebar" />
)}
</span>
<span className="flex-1 truncate font-medium">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="size-3 text-muted-foreground" />
</SidebarMenuButton>
}
/>
<DropdownMenuContent
className="w-auto min-w-56"
align="start"
side="bottom"
sideOffset={4}
>
<div className="flex items-center gap-2.5 px-2 py-1.5">
<ActorAvatar
name={user?.name ?? ""}
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
avatarUrl={user?.avatar_url}
size={32}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium leading-tight">
{user?.name}
</p>
<p className="truncate text-xs text-muted-foreground leading-tight">
{user?.email}
</p>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t(($) => $.sidebar.workspaces_label)}
</DropdownMenuLabel>
{workspaces.map((ws) => (
<DropdownMenuItem
key={ws.id}
render={
<AppLink href={paths.workspace(ws.slug).issues()} />
}
>
<WorkspaceAvatar name={ws.name} size="sm" />
<span className="flex-1 truncate">{ws.name}</span>
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</DropdownMenuItem>
))}
<DropdownMenuItem
onClick={() =>
useModalStore.getState().open("create-workspace")
}
>
<Plus className="h-3.5 w-3.5" />
{t(($) => $.sidebar.create_workspace)}
</DropdownMenuItem>
</DropdownMenuGroup>
{myInvitations.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t(($) => $.sidebar.pending_invitations_label)}
</DropdownMenuLabel>
{myInvitations.map((inv) => (
<div key={inv.id} className="flex items-center gap-2 px-2 py-1.5">
<WorkspaceAvatar name={inv.workspace_name ?? "W"} size="sm" />
<span className="flex-1 truncate text-sm">{inv.workspace_name ?? t(($) => $.sidebar.invitation_workspace_fallback)}</span>
<button
type="button"
className="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={acceptInvitationMut.isPending}
onClick={(e) => {
e.stopPropagation();
acceptInvitationMut.mutate(inv.id);
}}
>
{t(($) => $.sidebar.invitation_join)}
</button>
<button
type="button"
className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50"
disabled={declineInvitationMut.isPending}
onClick={(e) => {
e.stopPropagation();
declineInvitationMut.mutate(inv.id);
}}
>
{t(($) => $.sidebar.invitation_decline)}
</button>
</div>
))}
</DropdownMenuGroup>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={logout}>
<LogOut className="h-3.5 w-3.5" />
{t(($) => $.sidebar.log_out)}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{searchSlot && (
<SidebarMenuItem>
{searchSlot}
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton
className="text-muted-foreground"
onClick={() => useModalStore.getState().open("quick-create-issue")}
>
<span className="relative">
<SquarePen />
<DraftDot />
</span>
<span>{t(($) => $.sidebar.new_issue)}</span>
<kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">{t(($) => $.sidebar.new_issue_shortcut)}</kbd>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
{/* Navigation */}
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{personalNav.map((item) => {
const href = p[item.key]();
const isActive = isNavActive(pathname, href);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
isActive={isActive}
render={<AppLink href={href} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{t(($) => $.nav[item.labelKey])}</span>
{item.key === "inbox" && unreadCount > 0 && (
<span className="ml-auto text-xs">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{localPinned.length > 0 && (
<Collapsible defaultOpen>
<SidebarGroup className="group/pinned">
<SidebarGroupLabel
render={<CollapsibleTrigger />}
className="group/trigger cursor-pointer hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground"
>
<span>{t(($) => $.sidebar.pinned_label)}</span>
<ChevronRight className="!size-3 ml-1 stroke-[2.5] transition-transform duration-200 group-data-[panel-open]/trigger:rotate-90" />
<span className="ml-auto text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/pinned:opacity-100">{localPinned.length}</span>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<SortableContext items={localPinned.map((p) => p.id)} strategy={verticalListSortingStrategy}>
<SidebarMenu className="gap-0.5">
{localPinned.map((pin: PinnedItem) => (
<PinRow
key={pin.id}
pin={pin}
href={pin.item_type === "issue" ? p.issueDetail(pin.item_id) : p.projectDetail(pin.item_id)}
pathname={pathname}
onUnpin={() => deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })}
wsId={wsId ?? ""}
/>
))}
</SidebarMenu>
</SortableContext>
</DndContext>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
<SidebarGroup>
<SidebarGroupLabel>{t(($) => $.sidebar.workspace_group)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{workspaceNav.map((item) => {
const href = p[item.key]();
const isActive = isNavActive(pathname, href);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
isActive={isActive}
render={<AppLink href={href} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{t(($) => $.nav[item.labelKey])}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>{t(($) => $.sidebar.configure_group)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{configureNav.map((item) => {
const href = p[item.key]();
const isActive = isNavActive(pathname, href);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
isActive={isActive}
render={<AppLink href={href} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{t(($) => $.nav[item.labelKey])}</span>
{item.key === "runtimes" && hasRuntimeUpdates && (
<span className="ml-auto size-1.5 rounded-full bg-destructive" />
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-2">
<div className="flex justify-end">
<HelpLauncher />
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}