mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 14:37:44 +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>
853 lines
32 KiB
TypeScript
853 lines
32 KiB
TypeScript
"use client";
|
||
|
||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { motion } from "motion/react";
|
||
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check, History } from "lucide-react";
|
||
import { Button } from "@multica/ui/components/ui/button";
|
||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuGroup,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@multica/ui/components/ui/dropdown-menu";
|
||
import { useWorkspaceId } from "@multica/core/hooks";
|
||
import { useAuthStore } from "@multica/core/auth";
|
||
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
|
||
import { canAssignAgent } from "@multica/views/issues/components";
|
||
import { api } from "@multica/core/api";
|
||
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
|
||
import { ActorAvatar } from "../../common/actor-avatar";
|
||
import { OfflineBanner } from "./offline-banner";
|
||
import { NoAgentBanner } from "./no-agent-banner";
|
||
import {
|
||
chatSessionsOptions,
|
||
allChatSessionsOptions,
|
||
chatMessagesOptions,
|
||
pendingChatTaskOptions,
|
||
pendingChatTasksOptions,
|
||
chatKeys,
|
||
} from "@multica/core/chat/queries";
|
||
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
|
||
import { useChatStore } from "@multica/core/chat";
|
||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||
import { ChatInput } from "./chat-input";
|
||
import { ChatSessionHistory } from "./chat-session-history";
|
||
import {
|
||
ContextAnchorButton,
|
||
ContextAnchorCard,
|
||
buildAnchorMarkdown,
|
||
useRouteAnchorCandidate,
|
||
} from "./context-anchor";
|
||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||
import { useChatResize } from "./use-chat-resize";
|
||
import { createLogger } from "@multica/core/logger";
|
||
import type { Agent, ChatMessage, ChatPendingTask, ChatSession } from "@multica/core/types";
|
||
import { useT } from "../../i18n";
|
||
|
||
const uiLogger = createLogger("chat.ui");
|
||
const apiLogger = createLogger("chat.api");
|
||
|
||
export function ChatWindow() {
|
||
const { t } = useT("chat");
|
||
const wsId = useWorkspaceId();
|
||
const isOpen = useChatStore((s) => s.isOpen);
|
||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
||
const setOpen = useChatStore((s) => s.setOpen);
|
||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
|
||
const showHistory = useChatStore((s) => s.showHistory);
|
||
const setShowHistory = useChatStore((s) => s.setShowHistory);
|
||
const user = useAuthStore((s) => s.user);
|
||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
|
||
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
|
||
chatMessagesOptions(activeSessionId ?? ""),
|
||
);
|
||
// When no active session, always show empty — don't use stale cache
|
||
const messages = activeSessionId ? rawMessages ?? [] : [];
|
||
// Skeleton only shows for an un-cached session fetch. Cached switches
|
||
// return data synchronously — no flash. `enabled: false` (new chat)
|
||
// keeps isLoading false so the starter prompts aren't hidden.
|
||
const showSkeleton = !!activeSessionId && messagesLoading;
|
||
|
||
// Server-authoritative pending task. Survives refresh / reopen / session
|
||
// switch because it's keyed on sessionId in the Query cache; WS events
|
||
// (chat:message / chat:done / task:*) keep it invalidated in real time.
|
||
//
|
||
// This is the SOLE source for pendingTaskId — no mirror in the store.
|
||
const { data: pendingTask } = useQuery(
|
||
pendingChatTaskOptions(activeSessionId ?? ""),
|
||
);
|
||
const pendingTaskId = pendingTask?.task_id ?? null;
|
||
|
||
// Legacy archived sessions (the old soft-archive feature was removed but
|
||
// pre-existing rows with status='archived' may still exist) render as
|
||
// read-only: history list keeps showing them, but ChatInput is disabled
|
||
// and the server still rejects POST /messages for them.
|
||
const currentSession = activeSessionId
|
||
? allSessions.find((s) => s.id === activeSessionId)
|
||
: null;
|
||
const isSessionArchived = currentSession?.status === "archived";
|
||
|
||
const qc = useQueryClient();
|
||
const createSession = useCreateChatSession();
|
||
const markRead = useMarkChatSessionRead();
|
||
|
||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||
const memberRole = currentMember?.role;
|
||
const availableAgents = agents.filter(
|
||
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
|
||
);
|
||
|
||
// Resolve selected agent: stored preference → first available
|
||
const activeAgent =
|
||
availableAgents.find((a) => a.id === selectedAgentId) ??
|
||
availableAgents[0] ??
|
||
null;
|
||
|
||
// Three-state availability — "loading" stays neutral (no banner, no
|
||
// disable) so the input doesn't flash a fake "no agent" state in the
|
||
// few hundred ms before the agent list query resolves. Only `"none"`
|
||
// (server confirmed: zero usable agents) drives the disabled UI.
|
||
const agentAvailability = useWorkspaceAgentAvailability();
|
||
const noAgent = agentAvailability === "none";
|
||
|
||
// Presence drives both the avatar status dot (via ActorAvatar) and the
|
||
// OfflineBanner / TaskStatusPill availability copy. `useAgentPresenceDetail`
|
||
// returns "loading" while queries are still resolving — pass `undefined`
|
||
// downstream so banners and pill copy stay silent during loading rather
|
||
// than flash speculative offline text.
|
||
const presenceDetail = useAgentPresenceDetail(wsId, activeAgent?.id);
|
||
const availability =
|
||
presenceDetail === "loading" ? undefined : presenceDetail.availability;
|
||
|
||
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
|
||
// fires on layout mount (login / workspace switch / fresh page load).
|
||
useEffect(() => {
|
||
uiLogger.info("ChatWindow mount", {
|
||
isOpen,
|
||
activeSessionId,
|
||
pendingTaskId,
|
||
selectedAgentId,
|
||
wsId,
|
||
});
|
||
return () => {
|
||
uiLogger.info("ChatWindow unmount", {
|
||
activeSessionId,
|
||
pendingTaskId,
|
||
});
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
|
||
}, []);
|
||
|
||
// Open intent is fully driven by `activeSessionId` in storage — no mount
|
||
// restore, no self-heal. Adding either reintroduces a "two signals
|
||
// describing one fact" race (the previous self-heal mis-cleared the
|
||
// freshly-created session because allSessions was still stale during the
|
||
// post-create invalidate-refetch window).
|
||
|
||
// WS events are handled globally in useRealtimeSync — the query cache
|
||
// stays current even when this window is closed. See packages/core/realtime/.
|
||
|
||
// Auto mark-as-read whenever the user is looking at a session with unread
|
||
// state: window open + a session active + has_unread → PATCH.
|
||
// has_unread comes from the list query; WS handlers invalidate it on
|
||
// chat:done so a reply arriving while the user watches triggers this
|
||
// effect again and is instantly cleared.
|
||
const currentHasUnread =
|
||
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
|
||
useEffect(() => {
|
||
if (!isOpen || !activeSessionId) return;
|
||
if (!currentHasUnread) return;
|
||
uiLogger.info("auto markRead", { sessionId: activeSessionId });
|
||
markRead.mutate(activeSessionId);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
|
||
}, [isOpen, activeSessionId, currentHasUnread]);
|
||
|
||
// Focus-mode anchor: derived from route each render. Prepended to the
|
||
// outgoing message when focus is on; the anchor persists across sends
|
||
// (focus mode tracks the user's page, not a per-message attachment).
|
||
const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
|
||
|
||
const handleSend = useCallback(
|
||
async (content: string) => {
|
||
if (!activeAgent) {
|
||
apiLogger.warn("sendChatMessage skipped: no active agent");
|
||
return;
|
||
}
|
||
|
||
const focusOn = useChatStore.getState().focusMode;
|
||
const finalContent = focusOn && anchorCandidate
|
||
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
|
||
: content;
|
||
|
||
let sessionId = activeSessionId;
|
||
const isNewSession = !sessionId;
|
||
|
||
apiLogger.info("sendChatMessage.start", {
|
||
sessionId,
|
||
isNewSession,
|
||
agentId: activeAgent.id,
|
||
contentLength: finalContent.length,
|
||
hasAnchor: focusOn && !!anchorCandidate,
|
||
});
|
||
|
||
if (!sessionId) {
|
||
const session = await createSession.mutateAsync({
|
||
agent_id: activeAgent.id,
|
||
title: finalContent.slice(0, 50),
|
||
});
|
||
sessionId = session.id;
|
||
setActiveSession(sessionId);
|
||
}
|
||
|
||
// Optimistic burst — everything that gives the user "I sent a message
|
||
// and the agent is now working" feedback fires BEFORE the HTTP roundtrip.
|
||
// Pre-#status-pill the pending-task seed lived after `await
|
||
// sendChatMessage` and the pill blinked in a few hundred ms after the
|
||
// user's message — small but visible "did it actually send?" gap.
|
||
const sentAt = new Date().toISOString();
|
||
const optimistic: ChatMessage = {
|
||
id: `optimistic-${Date.now()}`,
|
||
chat_session_id: sessionId,
|
||
role: "user",
|
||
content: finalContent,
|
||
task_id: null,
|
||
created_at: sentAt,
|
||
};
|
||
qc.setQueryData<ChatMessage[]>(
|
||
chatKeys.messages(sessionId),
|
||
(old) => (old ? [...old, optimistic] : [optimistic]),
|
||
);
|
||
// Seed the pending-task with a temporary id so the StatusPill mounts
|
||
// and starts ticking the instant the user clicks send. Real task_id
|
||
// and server-authoritative created_at land below; until then the pill
|
||
// is anchored to the local clock (drift is the request RTT, ~50–200ms,
|
||
// which doesn't change the rendered "Ns" value).
|
||
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
|
||
task_id: `optimistic-${optimistic.id}`,
|
||
status: "queued",
|
||
created_at: sentAt,
|
||
});
|
||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||
|
||
const result = await api.sendChatMessage(sessionId, finalContent);
|
||
apiLogger.info("sendChatMessage.success", {
|
||
sessionId,
|
||
messageId: result.message_id,
|
||
taskId: result.task_id,
|
||
});
|
||
// Replace the temporary task_id with the server's real one (so the WS
|
||
// task: handlers can match against it) and snap the anchor to the
|
||
// server's created_at — keeping the elapsed-seconds reading stable.
|
||
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
|
||
task_id: result.task_id,
|
||
status: "queued",
|
||
created_at: result.created_at,
|
||
});
|
||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||
},
|
||
[
|
||
activeSessionId,
|
||
activeAgent,
|
||
anchorCandidate,
|
||
createSession,
|
||
setActiveSession,
|
||
qc,
|
||
],
|
||
);
|
||
|
||
const handleStop = useCallback(() => {
|
||
if (!pendingTaskId || !activeSessionId) {
|
||
apiLogger.debug("cancelTask skipped: no pending task");
|
||
return;
|
||
}
|
||
// Optimistic clear — pill disappears + input unlocks the moment the
|
||
// user clicks Stop, instead of after the HTTP roundtrip. WS
|
||
// task:cancelled will confirm later (no-op if cache is already empty);
|
||
// if the cancel POST fails because the task already finished, the
|
||
// assistant message arrives via task:completed → chat:done and renders
|
||
// normally. Either way the UI is in sync with reality without latency.
|
||
apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId });
|
||
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
|
||
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
|
||
// Fire-and-forget — UI is already in its post-cancel state. We log the
|
||
// outcome but never block on it.
|
||
api.cancelTaskById(pendingTaskId).then(
|
||
() => apiLogger.info("cancelTask.success", { taskId: pendingTaskId }),
|
||
(err) =>
|
||
apiLogger.warn("cancelTask.error (task may have already finished)", {
|
||
taskId: pendingTaskId,
|
||
err,
|
||
}),
|
||
);
|
||
}, [pendingTaskId, activeSessionId, qc]);
|
||
|
||
const handleSelectAgent = useCallback(
|
||
(agent: Agent) => {
|
||
// No-op when clicking the already-active agent — don't clobber the
|
||
// current session just because the user closed the menu this way.
|
||
// Compare against activeAgent (what the UI shows), not selectedAgentId
|
||
// (which may be null / point to an archived agent on first load).
|
||
if (activeAgent && agent.id === activeAgent.id) return;
|
||
uiLogger.info("selectAgent", {
|
||
from: selectedAgentId,
|
||
to: agent.id,
|
||
previousSessionId: activeSessionId,
|
||
});
|
||
setSelectedAgentId(agent.id);
|
||
// Reset session when switching agent
|
||
setActiveSession(null);
|
||
},
|
||
[activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession],
|
||
);
|
||
|
||
const handleNewChat = useCallback(() => {
|
||
uiLogger.info("newChat", {
|
||
previousSessionId: activeSessionId,
|
||
previousPendingTask: pendingTaskId,
|
||
});
|
||
setActiveSession(null);
|
||
}, [activeSessionId, pendingTaskId, setActiveSession]);
|
||
|
||
const handleSelectSession = useCallback(
|
||
(session: ChatSession) => {
|
||
// Sessions are bound 1:1 to an agent — picking a session from a
|
||
// different agent implicitly switches the agent too.
|
||
if (activeAgent && session.agent_id !== activeAgent.id) {
|
||
uiLogger.info("selectSession (cross-agent)", {
|
||
from: activeAgent.id,
|
||
toAgent: session.agent_id,
|
||
toSession: session.id,
|
||
});
|
||
setSelectedAgentId(session.agent_id);
|
||
}
|
||
setActiveSession(session.id);
|
||
},
|
||
[activeAgent, setSelectedAgentId, setActiveSession],
|
||
);
|
||
|
||
const handleMinimize = useCallback(() => {
|
||
uiLogger.info("minimize (close)", {
|
||
activeSessionId,
|
||
pendingTaskId,
|
||
});
|
||
setOpen(false);
|
||
}, [activeSessionId, pendingTaskId, setOpen]);
|
||
|
||
const isExpanded = useChatStore((s) => s.isExpanded);
|
||
|
||
const windowRef = useRef<HTMLDivElement>(null);
|
||
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
|
||
|
||
// Show the list (vs empty state) as soon as there's anything to display —
|
||
// a real message, or a pending task whose timeline will stream in.
|
||
const hasMessages = messages.length > 0 || !!pendingTaskId;
|
||
|
||
const isVisible = isOpen && (isExpanded || boundsReady);
|
||
|
||
const containerClass = isExpanded
|
||
? "absolute inset-3 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden"
|
||
: "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
|
||
const containerStyle: React.CSSProperties = {
|
||
...(!isExpanded ? { width: renderWidth, height: renderHeight } : {}),
|
||
transformOrigin: "bottom right",
|
||
pointerEvents: isOpen ? "auto" : "none",
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
ref={windowRef}
|
||
className={containerClass}
|
||
style={containerStyle}
|
||
layout="position"
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{
|
||
opacity: isVisible ? 1 : 0,
|
||
scale: isVisible ? 1 : 0.95,
|
||
}}
|
||
transition={{
|
||
layout: isDragging
|
||
? { duration: 0 }
|
||
: { type: "spring", duration: 0.3, bounce: 0 },
|
||
opacity: { duration: 0.15 },
|
||
scale: { type: "spring", duration: 0.2, bounce: 0 },
|
||
}}
|
||
>
|
||
{!isExpanded && <ChatResizeHandles onDragStart={startDrag} />}
|
||
{/* Header — ⊕ new + session dropdown | window tools */}
|
||
<div className="flex items-center justify-between border-b px-4 py-2.5 gap-2">
|
||
<div className="flex items-center gap-1 min-w-0">
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="rounded-full text-muted-foreground"
|
||
onClick={handleNewChat}
|
||
/>
|
||
}
|
||
>
|
||
<Plus />
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">{t(($) => $.window.new_chat_tooltip)}</TooltipContent>
|
||
</Tooltip>
|
||
<SessionDropdown
|
||
sessions={sessions}
|
||
// Use the full agent list (incl. archived) so historical
|
||
// sessions can still resolve their avatar.
|
||
agents={agents}
|
||
activeSessionId={activeSessionId}
|
||
onSelectSession={handleSelectSession}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-0.5 shrink-0">
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground data-[active=true]:bg-accent"
|
||
data-active={showHistory ? "true" : undefined}
|
||
onClick={() => setShowHistory(!showHistory)}
|
||
/>
|
||
}
|
||
>
|
||
<History />
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">
|
||
{showHistory ? t(($) => $.window.history_back_tooltip) : t(($) => $.window.history_show_tooltip)}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground"
|
||
onClick={toggleExpand}
|
||
/>
|
||
}
|
||
>
|
||
{isExpanded || isAtMax ? <Minimize2 /> : <Maximize2 />}
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">
|
||
{isExpanded || isAtMax ? t(($) => $.window.restore_tooltip) : t(($) => $.window.expand_tooltip)}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="text-muted-foreground"
|
||
onClick={handleMinimize}
|
||
/>
|
||
}
|
||
>
|
||
<Minus />
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">{t(($) => $.window.minimize_tooltip)}</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
{/* History panel takes over the body when toggled — surfaces the
|
||
* per-row delete button. Hidden by default; the input + banners
|
||
* are skipped here because the panel has its own affordances. */}
|
||
{showHistory ? (
|
||
<ChatSessionHistory />
|
||
) : (
|
||
<>
|
||
{/* Messages / skeleton / empty state */}
|
||
{showSkeleton ? (
|
||
<ChatMessageSkeleton />
|
||
) : hasMessages ? (
|
||
<ChatMessageList
|
||
messages={messages}
|
||
pendingTask={pendingTask}
|
||
availability={availability}
|
||
/>
|
||
) : (
|
||
<EmptyState
|
||
hasSessions={sessions.length > 0}
|
||
agentName={activeAgent?.name}
|
||
onPickPrompt={(text) => handleSend(text)}
|
||
/>
|
||
)}
|
||
|
||
{/* Status banner above the input — single mutually-exclusive slot.
|
||
* Priority: no-agent > offline / unstable. Agent presence is the
|
||
* hard prerequisite (you can't send anything without one), so it
|
||
* always wins over a presence hint. ContextAnchorCard stays in
|
||
* topSlot because that's per-message context, not session state.
|
||
*
|
||
* We key off `noAgent` (the resolved-empty state) rather than
|
||
* `!activeAgent`, so the loading window between mount and the
|
||
* first agent-list response stays banner-free. */}
|
||
{noAgent ? (
|
||
<NoAgentBanner />
|
||
) : (
|
||
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
|
||
)}
|
||
|
||
{/* Input — disabled for legacy archived sessions; locked out entirely
|
||
* when there's no agent (the EmptyState above carries the CTA). */}
|
||
<ChatInput
|
||
onSend={handleSend}
|
||
onStop={handleStop}
|
||
isRunning={!!pendingTaskId}
|
||
disabled={isSessionArchived}
|
||
noAgent={noAgent}
|
||
agentName={activeAgent?.name}
|
||
topSlot={<ContextAnchorCard />}
|
||
leftAdornment={
|
||
<AgentDropdown
|
||
agents={availableAgents}
|
||
activeAgent={activeAgent}
|
||
userId={user?.id}
|
||
onSelect={handleSelectAgent}
|
||
/>
|
||
}
|
||
rightAdornment={<ContextAnchorButton />}
|
||
/>
|
||
</>
|
||
)}
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Agent dropdown: avatar trigger, lists all available agents. Selecting a
|
||
* different agent = switch agent + start a fresh chat (session=null).
|
||
* The current agent is marked with a check and not clickable.
|
||
*/
|
||
function AgentDropdown({
|
||
agents,
|
||
activeAgent,
|
||
userId,
|
||
onSelect,
|
||
}: {
|
||
agents: Agent[];
|
||
activeAgent: Agent | null;
|
||
userId: string | undefined;
|
||
onSelect: (agent: Agent) => void;
|
||
}) {
|
||
const { t } = useT("chat");
|
||
// Split into the user's own agents and everyone else so the menu groups
|
||
// them — matches the old AgentSelector layout.
|
||
const { mine, others } = useMemo(() => {
|
||
const mine: Agent[] = [];
|
||
const others: Agent[] = [];
|
||
for (const a of agents) {
|
||
if (a.owner_id === userId) mine.push(a);
|
||
else others.push(a);
|
||
}
|
||
return { mine, others };
|
||
}, [agents, userId]);
|
||
|
||
if (!activeAgent) {
|
||
return <span className="text-xs text-muted-foreground">{t(($) => $.window.no_agents)}</span>;
|
||
}
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||
<ActorAvatar
|
||
actorType="agent"
|
||
actorId={activeAgent.id}
|
||
size={24}
|
||
enableHoverCard
|
||
showStatusDot
|
||
/>
|
||
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
|
||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
|
||
{mine.length > 0 && (
|
||
<DropdownMenuGroup>
|
||
<DropdownMenuLabel>{t(($) => $.window.my_agents)}</DropdownMenuLabel>
|
||
{mine.map((agent) => (
|
||
<AgentMenuItem
|
||
key={agent.id}
|
||
agent={agent}
|
||
isCurrent={agent.id === activeAgent.id}
|
||
onSelect={onSelect}
|
||
/>
|
||
))}
|
||
</DropdownMenuGroup>
|
||
)}
|
||
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
|
||
{others.length > 0 && (
|
||
<DropdownMenuGroup>
|
||
<DropdownMenuLabel>{t(($) => $.window.others)}</DropdownMenuLabel>
|
||
{others.map((agent) => (
|
||
<AgentMenuItem
|
||
key={agent.id}
|
||
agent={agent}
|
||
isCurrent={agent.id === activeAgent.id}
|
||
onSelect={onSelect}
|
||
/>
|
||
))}
|
||
</DropdownMenuGroup>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
function AgentMenuItem({
|
||
agent,
|
||
isCurrent,
|
||
onSelect,
|
||
}: {
|
||
agent: Agent;
|
||
isCurrent: boolean;
|
||
onSelect: (agent: Agent) => void;
|
||
}) {
|
||
return (
|
||
<DropdownMenuItem
|
||
onClick={() => onSelect(agent)}
|
||
className="flex min-w-0 items-center gap-2"
|
||
>
|
||
<ActorAvatar
|
||
actorType="agent"
|
||
actorId={agent.id}
|
||
size={24}
|
||
enableHoverCard
|
||
showStatusDot
|
||
/>
|
||
<span className="truncate flex-1">{agent.name}</span>
|
||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||
</DropdownMenuItem>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Session dropdown: lists ALL sessions across agents. Each row carries the
|
||
* owning agent's avatar so the user can tell them apart. Selecting a
|
||
* session from a different agent implicitly switches the agent too
|
||
* (sessions are bound 1:1 to an agent). "New chat" lives in the header's
|
||
* ⊕ button, not inside this dropdown.
|
||
*/
|
||
function SessionDropdown({
|
||
sessions,
|
||
agents,
|
||
activeSessionId,
|
||
onSelectSession,
|
||
}: {
|
||
sessions: ChatSession[];
|
||
agents: Agent[];
|
||
activeSessionId: string | null;
|
||
onSelectSession: (session: ChatSession) => void;
|
||
}) {
|
||
const { t } = useT("chat");
|
||
const wsId = useWorkspaceId();
|
||
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
||
const title = activeSession?.title?.trim() || t(($) => $.window.untitled);
|
||
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
|
||
|
||
// Aggregate "which sessions have an in-flight task right now". Reuses
|
||
// the same workspace-scoped query the FAB consumes, so toggling the chat
|
||
// window doesn't fire a second request — TanStack dedupes by key.
|
||
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
|
||
const inFlightSessionIds = useMemo(
|
||
() => new Set((pending?.tasks ?? []).map((t) => t.chat_session_id)),
|
||
[pending],
|
||
);
|
||
|
||
// Cross-session aggregate signal for the closed-dropdown trigger.
|
||
// "Active" here means there's something interesting happening in a
|
||
// session OTHER than the one the user is currently looking at — the
|
||
// user already sees their own session's state via the StatusPill /
|
||
// unread auto-mark, so highlighting it on the trigger would be noise.
|
||
// Same priority rule as the row pips: running > unread.
|
||
const otherSessionRunning = sessions.some(
|
||
(s) => s.id !== activeSessionId && inFlightSessionIds.has(s.id),
|
||
);
|
||
const otherSessionUnread = sessions.some(
|
||
(s) => s.id !== activeSessionId && s.has_unread,
|
||
);
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||
{triggerAgent && (
|
||
<ActorAvatar
|
||
actorType="agent"
|
||
actorId={triggerAgent.id}
|
||
size={24}
|
||
enableHoverCard
|
||
showStatusDot
|
||
/>
|
||
)}
|
||
<span className="truncate text-sm font-medium">{title}</span>
|
||
{otherSessionRunning ? (
|
||
<span
|
||
aria-label={t(($) => $.window.another_running)}
|
||
title={t(($) => $.window.another_running)}
|
||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||
/>
|
||
) : otherSessionUnread ? (
|
||
<span
|
||
aria-label={t(($) => $.window.another_unread)}
|
||
title={t(($) => $.window.another_unread)}
|
||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||
/>
|
||
) : null}
|
||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="start" className="max-h-80 w-auto min-w-56 max-w-80">
|
||
{sessions.length === 0 ? (
|
||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||
{t(($) => $.window.no_previous)}
|
||
</div>
|
||
) : (
|
||
sessions.map((session) => {
|
||
const isCurrent = session.id === activeSessionId;
|
||
const agent = agentById.get(session.agent_id) ?? null;
|
||
const isRunning = inFlightSessionIds.has(session.id);
|
||
return (
|
||
<DropdownMenuItem
|
||
key={session.id}
|
||
onClick={() => onSelectSession(session)}
|
||
className="flex min-w-0 items-center gap-2"
|
||
>
|
||
{agent ? (
|
||
<ActorAvatar
|
||
actorType="agent"
|
||
actorId={agent.id}
|
||
size={24}
|
||
enableHoverCard
|
||
showStatusDot
|
||
/>
|
||
) : (
|
||
<span className="size-6 shrink-0" />
|
||
)}
|
||
<span className="truncate flex-1 text-sm">
|
||
{session.title?.trim() || t(($) => $.window.untitled)}
|
||
</span>
|
||
{/* Right-edge status pip: in-flight wins over unread because
|
||
* "still working" is more actionable than "has reply" — and
|
||
* the two rarely coexist in practice (the unread flag fires
|
||
* on chat_message write, by which point the task has just
|
||
* finished). Same pip shape as unread for visual rhythm,
|
||
* amber + pulse to read as activity. */}
|
||
{isRunning ? (
|
||
<span
|
||
aria-label={t(($) => $.window.running)}
|
||
title={t(($) => $.window.running)}
|
||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||
/>
|
||
) : session.has_unread ? (
|
||
<span
|
||
aria-label={t(($) => $.window.unread)}
|
||
title={t(($) => $.window.unread)}
|
||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||
/>
|
||
) : null}
|
||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||
</DropdownMenuItem>
|
||
);
|
||
})
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
// Three starter prompts shown on the empty state. Each is keyed into the
|
||
// chat namespace so labels translate per locale; the icon stays raw since
|
||
// emojis are locale-neutral.
|
||
const STARTER_KEYS: ("list_open" | "summarize_today" | "plan_next")[] = [
|
||
"list_open",
|
||
"summarize_today",
|
||
"plan_next",
|
||
];
|
||
const STARTER_ICONS: Record<(typeof STARTER_KEYS)[number], string> = {
|
||
list_open: "📋",
|
||
summarize_today: "📝",
|
||
plan_next: "💡",
|
||
};
|
||
|
||
function EmptyState({
|
||
hasSessions,
|
||
agentName,
|
||
onPickPrompt,
|
||
}: {
|
||
hasSessions: boolean;
|
||
agentName?: string;
|
||
onPickPrompt: (text: string) => void;
|
||
}) {
|
||
const { t } = useT("chat");
|
||
// First-time experience: the user has never started a chat in this
|
||
// workspace. Educate before suggesting actions — starter prompts
|
||
// presume the user already knows what chat is for.
|
||
if (!hasSessions) {
|
||
return (
|
||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-8">
|
||
<div className="text-center space-y-3">
|
||
<h3 className="text-base font-semibold">
|
||
{t(($) => $.empty_state.first_time_title)}
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(($) => $.empty_state.first_time_intro)}{" "}
|
||
<span className="font-medium text-foreground">
|
||
{t(($) => $.empty_state.first_time_pillars)}
|
||
</span>
|
||
{t(($) => $.empty_state.first_time_pillars_suffix)}
|
||
</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(($) => $.empty_state.first_time_actions)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Returning user: starter prompts are the fastest path back to action.
|
||
return (
|
||
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
|
||
<div className="text-center space-y-1">
|
||
<h3 className="text-base font-semibold">
|
||
{agentName
|
||
? t(($) => $.empty_state.returning_title_named, { name: agentName })
|
||
: t(($) => $.empty_state.returning_title_default)}
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(($) => $.empty_state.returning_subtitle)}
|
||
</p>
|
||
</div>
|
||
<div className="w-full max-w-xs space-y-2">
|
||
{STARTER_KEYS.map((key) => {
|
||
const text = t(($) => $.starter_prompts[key]);
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => onPickPrompt(text)}
|
||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
|
||
>
|
||
<span className="mr-2">{STARTER_ICONS[key]}</span>
|
||
{text}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|