mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
* 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>
692 lines
26 KiB
TypeScript
692 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Check,
|
|
Clock,
|
|
Copy,
|
|
Link2,
|
|
Loader2,
|
|
MessageSquare,
|
|
Plus,
|
|
SearchIcon,
|
|
Inbox,
|
|
CircleUser,
|
|
ListTodo,
|
|
FolderKanban,
|
|
Bot,
|
|
Monitor,
|
|
Moon,
|
|
Sun,
|
|
BookOpenText,
|
|
Settings,
|
|
Building2,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { Command as CommandPrimitive } from "cmdk";
|
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
|
import { api } from "@multica/core/api";
|
|
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
|
import { issueDetailOptions } from "@multica/core/issues/queries";
|
|
import { useWorkspaceId } from "@multica/core";
|
|
import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
|
import type { WorkspacePaths } from "@multica/core/paths";
|
|
import { useModalStore } from "@multica/core/modals";
|
|
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
|
import { StatusIcon } from "../issues/components";
|
|
import { ProjectIcon } from "../projects/components/project-icon";
|
|
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
|
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
|
import type { ProjectStatus } from "@multica/core/types";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@multica/ui/components/ui/dialog";
|
|
import { useTheme } from "@multica/ui/components/common/theme-provider";
|
|
import { useNavigation } from "../navigation";
|
|
import { useT } from "../i18n";
|
|
import { useSearchStore } from "./search-store";
|
|
|
|
function HighlightText({ text, query }: { text: string; query: string }) {
|
|
const parts = useMemo(() => {
|
|
if (!query.trim()) return [{ text, highlight: false }];
|
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const regex = new RegExp(`(${escaped})`, "gi");
|
|
const result: { text: string; highlight: boolean }[] = [];
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = regex.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
result.push({ text: text.slice(lastIndex, match.index), highlight: false });
|
|
}
|
|
result.push({ text: match[0], highlight: true });
|
|
lastIndex = regex.lastIndex;
|
|
}
|
|
if (lastIndex < text.length) {
|
|
result.push({ text: text.slice(lastIndex), highlight: false });
|
|
}
|
|
return result.length > 0 ? result : [{ text, highlight: false }];
|
|
}, [text, query]);
|
|
|
|
return (
|
|
<>
|
|
{parts.map((part, i) =>
|
|
part.highlight ? (
|
|
<mark key={i} className="bg-yellow-200 dark:bg-yellow-900/60 text-inherit rounded-sm">
|
|
{part.text}
|
|
</mark>
|
|
) : (
|
|
part.text
|
|
),
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Nav items reference WorkspacePaths method names so they can be resolved
|
|
// against the current workspace slug at render time (see SearchCommand body).
|
|
// Only parameterless paths are valid nav destinations.
|
|
type NavKey =
|
|
| "inbox"
|
|
| "myIssues"
|
|
| "issues"
|
|
| "projects"
|
|
| "agents"
|
|
| "runtimes"
|
|
| "skills"
|
|
| "settings";
|
|
|
|
interface NavPage {
|
|
key: NavKey;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
keywords: string[];
|
|
}
|
|
|
|
type ThemeValue = "light" | "dark" | "system";
|
|
|
|
interface CommandItem {
|
|
key: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
keywords: string[];
|
|
trailing?: React.ReactNode;
|
|
onSelect: () => void;
|
|
}
|
|
|
|
interface SearchResults {
|
|
issues: SearchIssueResult[];
|
|
projects: SearchProjectResult[];
|
|
}
|
|
|
|
export function SearchCommand() {
|
|
const { t } = useT("search");
|
|
const navPages: NavPage[] = [
|
|
{ key: "inbox", label: t(($) => $.pages.inbox), icon: Inbox, keywords: ["inbox", "notifications", "收件箱"] },
|
|
{ key: "myIssues", label: t(($) => $.pages.my_issues), icon: CircleUser, keywords: ["my", "issues", "assigned", "我的"] },
|
|
{ key: "issues", label: t(($) => $.pages.issues), icon: ListTodo, keywords: ["issues", "tasks", "bugs"] },
|
|
{ key: "projects", label: t(($) => $.pages.projects), icon: FolderKanban, keywords: ["projects", "kanban", "项目"] },
|
|
{ key: "agents", label: t(($) => $.pages.agents), icon: Bot, keywords: ["agents", "bots", "ai"] },
|
|
{ key: "runtimes", label: t(($) => $.pages.runtimes), icon: Monitor, keywords: ["runtimes", "environments"] },
|
|
{ key: "skills", label: t(($) => $.pages.skills), icon: BookOpenText, keywords: ["skills", "library"] },
|
|
{ key: "settings", label: t(($) => $.pages.settings), icon: Settings, keywords: ["settings", "config", "preferences", "设置"] },
|
|
];
|
|
const { push, pathname, getShareableUrl } = useNavigation();
|
|
const open = useSearchStore((s) => s.open);
|
|
const setOpen = useSearchStore((s) => s.setOpen);
|
|
const recentItems = useRecentIssuesStore((s) => s.items);
|
|
const wsId = useWorkspaceId();
|
|
const p: WorkspacePaths = useWorkspacePaths();
|
|
const { theme, setTheme } = useTheme();
|
|
const currentWorkspace = useCurrentWorkspace();
|
|
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
|
|
|
// Resolve each recent issue via its cached detail entry. Recent items are
|
|
// typically already in the detail cache because the user has opened them;
|
|
// if not, this triggers a lookup per id so Recent never depends on whether
|
|
// the issue falls inside the paginated list cache.
|
|
const recentDetailQueries = useQueries({
|
|
queries: recentItems.map((item) => issueDetailOptions(wsId, item.id)),
|
|
});
|
|
const recentIssues = useMemo(
|
|
() =>
|
|
recentDetailQueries.flatMap((q) => (q.data ? [q.data] : [])),
|
|
[recentDetailQueries],
|
|
);
|
|
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
const filteredPages = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return [];
|
|
return navPages.filter(
|
|
(page) =>
|
|
page.label.toLowerCase().includes(q) ||
|
|
page.keywords.some((kw) => kw.includes(q)),
|
|
);
|
|
}, [query]);
|
|
|
|
// Detect if current route is an issue detail page — /{slug}/issues/{id}.
|
|
// Falls back to null on any other route; used to gate issue-specific commands.
|
|
const currentIssueId = useMemo(() => {
|
|
const match = pathname.match(/\/issues\/([^/]+)$/);
|
|
const raw = match?.[1];
|
|
return raw ? decodeURIComponent(raw) : null;
|
|
}, [pathname]);
|
|
const { data: currentIssue = null } = useQuery({
|
|
...issueDetailOptions(wsId, currentIssueId ?? ""),
|
|
enabled: !!currentIssueId,
|
|
});
|
|
|
|
const commands = useMemo<CommandItem[]>(() => {
|
|
const activeThemeCheck = (value: ThemeValue) =>
|
|
theme === value ? (
|
|
<Check
|
|
aria-label={t(($) => $.commands.current_theme_aria)}
|
|
className="ml-auto size-4 shrink-0 text-muted-foreground"
|
|
/>
|
|
) : undefined;
|
|
|
|
const items: CommandItem[] = [
|
|
{
|
|
key: "new-issue",
|
|
label: t(($) => $.commands.new_issue),
|
|
icon: Plus,
|
|
keywords: ["new", "issue", "create", "add"],
|
|
onSelect: () => {
|
|
useModalStore.getState().open("quick-create-issue");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
{
|
|
key: "new-project",
|
|
label: t(($) => $.commands.new_project),
|
|
icon: Plus,
|
|
keywords: ["new", "project", "create", "add"],
|
|
onSelect: () => {
|
|
useModalStore.getState().open("create-project");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
];
|
|
|
|
if (currentIssue) {
|
|
const identifier = currentIssue.identifier;
|
|
items.push(
|
|
{
|
|
key: "copy-issue-link",
|
|
label: t(($) => $.commands.copy_issue_link),
|
|
icon: Link2,
|
|
keywords: ["copy", "link", "share", "url", identifier.toLowerCase()],
|
|
onSelect: () => {
|
|
const url = getShareableUrl ? getShareableUrl(pathname) : window.location.href;
|
|
void navigator.clipboard.writeText(url);
|
|
toast.success(t(($) => $.toast.link_copied));
|
|
setOpen(false);
|
|
},
|
|
},
|
|
{
|
|
key: "copy-issue-identifier",
|
|
label: t(($) => $.commands.copy_identifier, { identifier }),
|
|
icon: Copy,
|
|
keywords: ["copy", "id", "identifier", identifier.toLowerCase()],
|
|
onSelect: () => {
|
|
void navigator.clipboard.writeText(identifier);
|
|
toast.success(t(($) => $.toast.copied_identifier, { identifier }));
|
|
setOpen(false);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
items.push(
|
|
{
|
|
key: "theme-light",
|
|
label: t(($) => $.commands.switch_to_light),
|
|
icon: Sun,
|
|
keywords: ["light", "theme", "appearance", "mode", "bright"],
|
|
trailing: activeThemeCheck("light"),
|
|
onSelect: () => {
|
|
setTheme("light");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
{
|
|
key: "theme-dark",
|
|
label: t(($) => $.commands.switch_to_dark),
|
|
icon: Moon,
|
|
keywords: ["dark", "theme", "appearance", "mode", "night"],
|
|
trailing: activeThemeCheck("dark"),
|
|
onSelect: () => {
|
|
setTheme("dark");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
{
|
|
key: "theme-system",
|
|
label: t(($) => $.commands.use_system_theme),
|
|
icon: Monitor,
|
|
keywords: ["system", "theme", "appearance", "mode", "auto"],
|
|
trailing: activeThemeCheck("system"),
|
|
onSelect: () => {
|
|
setTheme("system");
|
|
setOpen(false);
|
|
},
|
|
},
|
|
);
|
|
|
|
return items;
|
|
}, [currentIssue, getShareableUrl, pathname, setOpen, setTheme, theme, t]);
|
|
|
|
const filteredCommands = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
// No query: only surface the primary creation action. Other commands
|
|
// (theme switches, copy actions, New Project) are revealed as the user
|
|
// types, leaving the empty-state space to Recent.
|
|
if (!q) return commands.filter((c) => c.key === "new-issue");
|
|
return commands.filter(
|
|
(c) =>
|
|
c.label.toLowerCase().includes(q) ||
|
|
c.keywords.some((kw) => kw.includes(q)),
|
|
);
|
|
}, [commands, query]);
|
|
|
|
// Only show workspaces different from the current one, and only after the
|
|
// user types >=2 chars — one char would match everything (e.g. "w").
|
|
const filteredWorkspaces = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return [];
|
|
const others = workspaces.filter((w) => w.id !== currentWorkspace?.id);
|
|
const wantsAll =
|
|
q.length >= 2 && ("workspace".startsWith(q) || "switch".startsWith(q));
|
|
return others.filter(
|
|
(w) =>
|
|
wantsAll ||
|
|
w.name.toLowerCase().includes(q) ||
|
|
w.slug.toLowerCase().includes(q),
|
|
);
|
|
}, [workspaces, currentWorkspace?.id, query]);
|
|
|
|
const hasResults = results.issues.length > 0 || results.projects.length > 0;
|
|
|
|
// Global Cmd+K / Ctrl+K shortcut
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
useSearchStore.getState().toggle();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, []);
|
|
|
|
// Close on single ESC — capture phase fires before base-ui Dialog's handlers
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handleEsc = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleEsc, true);
|
|
return () => document.removeEventListener("keydown", handleEsc, true);
|
|
}, [open, setOpen]);
|
|
|
|
// Cleanup debounce/abort on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
if (abortRef.current) abortRef.current.abort();
|
|
};
|
|
}, []);
|
|
|
|
// Reset state when dialog closes
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setQuery("");
|
|
setResults({ issues: [], projects: [] });
|
|
setIsLoading(false);
|
|
}
|
|
}, [open]);
|
|
|
|
const search = useCallback((q: string) => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
if (abortRef.current) abortRef.current.abort();
|
|
|
|
if (!q.trim()) {
|
|
setResults({ issues: [], projects: [] });
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
debounceRef.current = setTimeout(async () => {
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
try {
|
|
const [issueRes, projectRes] = await Promise.all([
|
|
api.searchIssues({
|
|
q: q.trim(),
|
|
limit: 20,
|
|
include_closed: true,
|
|
signal: controller.signal,
|
|
}),
|
|
api.searchProjects({
|
|
q: q.trim(),
|
|
limit: 10,
|
|
include_closed: true,
|
|
signal: controller.signal,
|
|
}),
|
|
]);
|
|
if (!controller.signal.aborted) {
|
|
setResults({
|
|
issues: issueRes.issues,
|
|
projects: projectRes.projects,
|
|
});
|
|
setIsLoading(false);
|
|
}
|
|
} catch {
|
|
if (!controller.signal.aborted) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}, 300);
|
|
}, []);
|
|
|
|
const handleValueChange = useCallback(
|
|
(value: string) => {
|
|
setQuery(value);
|
|
search(value);
|
|
},
|
|
[search],
|
|
);
|
|
|
|
const handleSelect = useCallback(
|
|
(value: string) => {
|
|
setOpen(false);
|
|
if (value.startsWith("project:")) {
|
|
// value is "project:<id>" — slice off the 8-char prefix to extract the id.
|
|
push(p.projectDetail(value.slice(8)));
|
|
} else {
|
|
push(p.issueDetail(value));
|
|
}
|
|
},
|
|
[push, setOpen, p],
|
|
);
|
|
|
|
const handlePageSelect = useCallback(
|
|
(key: NavKey) => {
|
|
setOpen(false);
|
|
push(p[key]());
|
|
},
|
|
[push, setOpen, p],
|
|
);
|
|
|
|
const handleSwitchWorkspace = useCallback(
|
|
(slug: string) => {
|
|
push(paths.workspace(slug).issues());
|
|
setOpen(false);
|
|
},
|
|
[push, setOpen],
|
|
);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent
|
|
finalFocus={false}
|
|
className="top-[20%] translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-xl!"
|
|
showCloseButton={false}
|
|
>
|
|
<DialogHeader className="sr-only">
|
|
<DialogTitle>{t(($) => $.title)}</DialogTitle>
|
|
<DialogDescription>
|
|
{t(($) => $.description)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<CommandPrimitive
|
|
shouldFilter={false}
|
|
className="flex size-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground"
|
|
>
|
|
{/* Search input */}
|
|
<div className="flex items-center gap-3 border-b px-4 py-3">
|
|
<SearchIcon className="size-5 shrink-0 text-muted-foreground" />
|
|
<CommandPrimitive.Input
|
|
placeholder={t(($) => $.placeholder)}
|
|
value={query}
|
|
onValueChange={handleValueChange}
|
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
<kbd className="hidden shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline">
|
|
ESC
|
|
</kbd>
|
|
</div>
|
|
|
|
{/* Results list */}
|
|
<CommandPrimitive.List className="max-h-[min(400px,50vh)] overflow-y-auto overflow-x-hidden">
|
|
{/* Pages section — only shown when query matches */}
|
|
{filteredPages.length > 0 && (
|
|
<CommandPrimitive.Group className="p-2">
|
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.groups.pages)}
|
|
</div>
|
|
{filteredPages.map((page) => (
|
|
<CommandPrimitive.Item
|
|
key={page.key}
|
|
value={`page:${page.key}`}
|
|
onSelect={() => handlePageSelect(page.key)}
|
|
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
|
>
|
|
<page.icon className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">
|
|
<HighlightText text={page.label} query={query} />
|
|
</span>
|
|
</CommandPrimitive.Item>
|
|
))}
|
|
</CommandPrimitive.Group>
|
|
)}
|
|
|
|
{/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */}
|
|
{filteredCommands.length > 0 && (
|
|
<CommandPrimitive.Group className="p-2">
|
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.groups.commands)}
|
|
</div>
|
|
{filteredCommands.map((cmd) => (
|
|
<CommandPrimitive.Item
|
|
key={cmd.key}
|
|
value={`command:${cmd.key}`}
|
|
onSelect={cmd.onSelect}
|
|
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
|
>
|
|
<cmd.icon className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">
|
|
<HighlightText text={cmd.label} query={query} />
|
|
</span>
|
|
{cmd.trailing}
|
|
</CommandPrimitive.Item>
|
|
))}
|
|
</CommandPrimitive.Group>
|
|
)}
|
|
|
|
{/* Workspaces section — switch to a different workspace, only shown when query matches */}
|
|
{filteredWorkspaces.length > 0 && (
|
|
<CommandPrimitive.Group className="p-2">
|
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.groups.switch_workspace)}
|
|
</div>
|
|
{filteredWorkspaces.map((ws) => (
|
|
<CommandPrimitive.Item
|
|
key={ws.id}
|
|
value={`workspace:${ws.id}`}
|
|
onSelect={() => handleSwitchWorkspace(ws.slug)}
|
|
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
|
>
|
|
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">
|
|
<HighlightText text={ws.name} query={query} />
|
|
</span>
|
|
<span className="ml-auto text-xs text-muted-foreground truncate">
|
|
{ws.slug}
|
|
</span>
|
|
</CommandPrimitive.Item>
|
|
))}
|
|
</CommandPrimitive.Group>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="flex items-center justify-center py-10">
|
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading &&
|
|
query.trim() &&
|
|
!hasResults &&
|
|
filteredPages.length === 0 &&
|
|
filteredCommands.length === 0 &&
|
|
filteredWorkspaces.length === 0 && (
|
|
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
|
|
{t(($) => $.empty.no_results)}
|
|
</CommandPrimitive.Empty>
|
|
)}
|
|
|
|
{!isLoading && results.projects.length > 0 && (
|
|
<CommandPrimitive.Group
|
|
heading={t(($) => $.groups.projects)}
|
|
className="p-2 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
|
>
|
|
{results.projects.map((project) => (
|
|
<CommandPrimitive.Item
|
|
key={`project:${project.id}`}
|
|
value={`project:${project.id}`}
|
|
onSelect={handleSelect}
|
|
className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<ProjectIcon project={project} size="md" />
|
|
<span className="truncate">
|
|
<HighlightText text={project.title} query={query} />
|
|
</span>
|
|
<span
|
|
className={`ml-auto text-xs shrink-0 ${PROJECT_STATUS_CONFIG[project.status as ProjectStatus]?.color ?? "text-muted-foreground"}`}
|
|
>
|
|
{PROJECT_STATUS_CONFIG[project.status as ProjectStatus]?.label ?? project.status}
|
|
</span>
|
|
</div>
|
|
{project.match_source === "description" &&
|
|
project.matched_snippet && (
|
|
<div className="flex items-start gap-2 pl-[26px]">
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
<HighlightText
|
|
text={project.matched_snippet}
|
|
query={query}
|
|
/>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</CommandPrimitive.Item>
|
|
))}
|
|
</CommandPrimitive.Group>
|
|
)}
|
|
|
|
{!isLoading && results.issues.length > 0 && (
|
|
<CommandPrimitive.Group
|
|
heading={t(($) => $.groups.issues)}
|
|
className="p-2 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
|
>
|
|
{results.issues.map((issue) => (
|
|
<CommandPrimitive.Item
|
|
key={issue.id}
|
|
value={issue.id}
|
|
onSelect={handleSelect}
|
|
className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<StatusIcon
|
|
status={issue.status}
|
|
className="size-4 shrink-0"
|
|
/>
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
{issue.identifier}
|
|
</span>
|
|
<span className="truncate">
|
|
<HighlightText text={issue.title} query={query} />
|
|
</span>
|
|
<span
|
|
className={`ml-auto text-xs shrink-0 ${STATUS_CONFIG[issue.status].iconColor}`}
|
|
>
|
|
{STATUS_CONFIG[issue.status].label}
|
|
</span>
|
|
</div>
|
|
{issue.match_source === "comment" &&
|
|
issue.matched_snippet && (
|
|
<div className="flex items-start gap-2 pl-[26px]">
|
|
<MessageSquare className="size-3 shrink-0 text-muted-foreground mt-0.5" />
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
<HighlightText
|
|
text={issue.matched_snippet}
|
|
query={query}
|
|
/>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</CommandPrimitive.Item>
|
|
))}
|
|
</CommandPrimitive.Group>
|
|
)}
|
|
|
|
{!isLoading && !query.trim() && recentIssues.length > 0 && (
|
|
<CommandPrimitive.Group className="p-2">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
|
<Clock className="size-3" />
|
|
<span>{t(($) => $.groups.recent)}</span>
|
|
</div>
|
|
{recentIssues.map((item) => (
|
|
<CommandPrimitive.Item
|
|
key={item.id}
|
|
value={item.id}
|
|
onSelect={handleSelect}
|
|
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
|
>
|
|
<StatusIcon
|
|
status={item.status}
|
|
className="size-4 shrink-0"
|
|
/>
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
{item.identifier}
|
|
</span>
|
|
<span className="truncate">{item.title}</span>
|
|
<span
|
|
className={`ml-auto text-xs shrink-0 ${STATUS_CONFIG[item.status]?.iconColor ?? ""}`}
|
|
>
|
|
{STATUS_CONFIG[item.status]?.label ?? ""}
|
|
</span>
|
|
</CommandPrimitive.Item>
|
|
))}
|
|
</CommandPrimitive.Group>
|
|
)}
|
|
|
|
{!isLoading && !query.trim() && recentIssues.length === 0 && (
|
|
<div className="px-5 py-4 text-center text-xs text-muted-foreground">
|
|
{t(($) => $.empty.type_to_search)}
|
|
</div>
|
|
)}
|
|
</CommandPrimitive.List>
|
|
</CommandPrimitive>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|