Compare commits

..

26 Commits

Author SHA1 Message Date
Jiang Bohan
8411a30943 fix(web): add /uploads/* rewrite for self-hosted deployments
On self-hosted deployments where the frontend is the public entrypoint,
uploaded files return 404 because /uploads/* requests aren't proxied to
the backend. Add a rewrite rule following the existing pattern for /api/*,
/ws, and /auth/*.

Closes #1004
2026-04-14 21:30:40 +08:00
Jiayuan Zhang
f0c0a64ddd feat(cli): support --version and -v flags on root command (#1007)
Use Cobra's built-in version support so `multica --version` and
`multica -v` print the same output as `multica version`.

Closes MUL-743
2026-04-14 21:14:00 +08:00
Naiyuan Qing
2ecddc8fc8 Merge pull request #1002 from multica-ai/chore/remove-desktop-remote-mode
chore(desktop): remove dev:desktop:remote proxy mode
2026-04-14 19:54:43 +08:00
Naiyuan Qing
2a2e6f4746 chore(desktop): remove dev:desktop:remote proxy mode
Drops the VITE_REMOTE_API Vite-proxy path introduced in be8b099c.
The remote-backend proxy is no longer needed; direct dev via
VITE_API_URL covers every workflow we still support.

- remove dev:desktop:remote (root) and dev:remote (desktop) scripts
- revert electron.vite.config.ts to a flat config — no loadEnv, no
  per-route proxies
- simplify App.tsx: single apiBaseUrl/wsUrl branch, and
  DAEMON_TARGET_API_URL derives directly from VITE_API_URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:53:55 +08:00
Bohan Jiang
6538496ee4 fix(daemon): sync workspaces from API before failing on empty runtime list (#1001)
When the CLI config has no watched workspaces (e.g. fresh desktop app
install), loadWatchedWorkspaces returns successfully but registers zero
runtimes. The runtime check immediately after fails with "no runtimes
registered" before workspaceSyncLoop gets a chance to discover
workspaces from the API.

Run one sync cycle inline when the watched list is empty so the daemon
can bootstrap itself without a pre-configured workspace list.
2026-04-14 19:52:52 +08:00
Naiyuan Qing
69ef002bbb Merge pull request #1000 from multica-ai/fix/desktop-titlebar-drag-region
fix(desktop): stop macOS traffic lights from hijacking titlebar & modals
2026-04-14 19:48:45 +08:00
Naiyuan Qing
7dad45d444 feat(desktop): immersive mode hides traffic lights for full-screen modals
Full-screen modals (create-workspace) covered the app titlebar, so the
Back button landed on top of the macOS traffic lights — where native
hit-test always wins and the button couldn't be clicked. The modal
also swallowed the window's drag region.

Introduce a desktop IPC channel window:setImmersive that calls
BrowserWindow.setWindowButtonVisibility, exposed through the existing
desktopAPI preload bridge. A small useImmersiveMode() hook in
@multica/views/platform toggles it for the component's lifetime and
is a no-op on web / non-macOS.

CreateWorkspaceModal now:
- calls useImmersiveMode() so traffic lights disappear while it's open
- adds a transparent top h-10 drag strip to restore window dragging
- moves the Back button from top-6 left-6 to top-12 left-12 with an
  explicit no-drag region so clicks always reach it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:41:49 +08:00
Naiyuan Qing
7ade4b432d fix(desktop): pad main top-bar when sidebar collapses so tabs don't sit under traffic lights
Extract the main-area top bar into a MainTopBar component so it can
read sidebar state via useSidebar(). When the sidebar is collapsed,
apply pl-20 (80px) to the drag header so the TabBar starts clear of
the macOS traffic-light hit-test region (~x=16..68) that always
wins over HTML clicks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:41:38 +08:00
LinYushen
cbb2cf0c6c chore(desktop): rebuild CLI on every bundle-cli run (#999)
bundle-cli.mjs now invokes `go build` with the same ldflags as
`make build` (version/commit/date) before copying the binary into
resources/bin/. Running this on every `pnpm dev:desktop`, `dev:remote`
and `package` guarantees the bundled CLI matches the current Go source,
so you can't accidentally ship a stale binary after editing server/
code. Go's build cache makes no-op builds ~a few hundred ms.

Graceful fallback preserved: if `go` is not on PATH (frontend-only
contributor), we warn, skip the build, and let cli-bootstrap download
the latest release at runtime. Compile errors remain fatal so broken
Go code blocks dev rather than silently falling back.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:33:39 +08:00
Naiyuan Qing
d94b704a71 Merge pull request #993 from multica-ai/feat/chat-reading-width
fix(chat): reading-width container + refresh placeholder on agent switch
2026-04-14 19:17:06 +08:00
Naiyuan Qing
76ba9cfb0b Merge pull request #995 from multica-ai/feat/chat-skeleton
feat(chat): skeleton while switching to an un-cached session
2026-04-14 19:16:48 +08:00
devv-eve
40aa23a528 feat(desktop): daemon management panel with sidebar status bar (#952)
* feat(desktop): add daemon management panel with sidebar status bar

Integrate multica daemon lifecycle management into the desktop app so
users can start/stop/restart the daemon and view live logs without
leaving the UI. Session tokens are automatically synced to the CLI
config file, making daemon authentication transparent.

- daemon-manager.ts: Electron main process module for daemon lifecycle
  (health polling, start/stop via CLI, token sync, log tail)
- Preload bridge: new daemonAPI with IPC for all daemon operations
- Sidebar bottomSlot: persistent daemon status indicator in sidebar
  footer (desktop-only, injected via AppSidebar slot)
- Daemon panel Sheet: right-side drawer with status details, controls,
  and real-time log viewer with auto-scroll and level coloring
- Token sync: on login and app startup, JWT is written to
  ~/.multica/config.json so daemon can authenticate seamlessly

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

* feat(desktop): add P1+P2 daemon features — runtimes card, auto-start, settings

P1: Runtimes page Local Daemon card
- Add topSlot prop to shared RuntimesPage for platform injection
- DaemonRuntimeCard shows status, agents, uptime with Start/Stop/
  Restart/Logs buttons (desktop-only, injected via slot)

P2: Auto-start and auto-stop
- Daemon auto-starts on app launch when user is authenticated
  (controlled by autoStart preference, default: true)
- Daemon auto-stops on app quit (controlled by autoStop preference,
  default: false — daemon keeps running in background by default)
- Preferences persisted to ~/.multica/desktop_prefs.json

P2: Daemon settings tab
- New "Daemon" tab in Settings > My Account section (desktop-only)
- Toggle auto-start and auto-stop behavior
- CLI installation status check with link to install guide
- SettingsPage gains extraAccountTabs prop for platform injection

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

* fix(desktop): address PR review feedback on daemon management

Must-fix:
- before-quit handler now calls event.preventDefault(), awaits
  stopDaemon(), then re-calls app.quit() so the daemon actually
  stops before the app exits
- Add concurrency guard (operationInProgress lock) in daemon-manager
  to reject overlapping start/stop/restart IPC calls
- Extract shared types (DaemonState, DaemonStatus, DaemonPrefs),
  constants (STATE_COLORS, STATE_LABELS), and formatUptime to
  apps/desktop/src/shared/daemon-types.ts — all renderer components
  now import from this single source

Should-fix:
- Log viewer uses monotonic counter (LogEntry.id) instead of array
  index as React key, preventing full re-renders on overflow
- All start/stop/restart handlers now show toast.error() with the
  error message when the operation fails
- startLogTail retries up to 5 times with 2s delay when the log
  file doesn't exist yet (handles first-run case)

Minor:
- Cache findCliBinary() result after first successful lookup

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

* fix(logger): suppress ANSI color codes when stderr is not a TTY

Detect whether stderr is connected to a terminal and set tint's NoColor
option accordingly. Previously daemon.log files contained raw escape
sequences like \033[2m and \033[92m which made them unreadable in the
Desktop log viewer and any non-TTY sink (docker logs, systemd, etc).

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

* feat(daemon): runtime watch/unwatch HTTP endpoints and denylist

Add GET/POST/DELETE /watch handlers on the daemon's health port so
clients (notably Desktop) can add or remove watched workspaces at
runtime without restarting the daemon or editing config.json. Each
handler updates in-memory state under d.mu and persists back to
~/.multica/profiles/<name>/config.json for survival across restarts.

- CLIConfig gains UnwatchedWorkspaces as an explicit opt-out denylist.
  syncWorkspacesFromAPI skips entries in the denylist so a manual
  unwatch isn't silently revived 30s later by the periodic sync.
- loadWatchedWorkspaces tolerates an empty config and returns nil
  instead of erroring out, because Desktop starts daemons with a
  fresh profile and relies on the sync loop / watch endpoint to
  populate the list.

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

* feat(desktop): bundled CLI, per-backend profile, and watch UI

Make the Desktop app self-sufficient: it bundles its own multica
binary, manages its own daemon profile keyed by the backend URL, and
authenticates that daemon with a long-lived PAT it mints on first
login. The daemon panel gains a checkbox list of watched workspaces
and surfaces the active profile + server URL.

CLI bootstrap
- scripts/bundle-cli.mjs copies server/bin/multica into
  apps/desktop/resources/bin/ before electron-vite dev and
  electron-builder package. asarUnpack: resources/** already covers
  this path, so the binary ships with the .app in prod.
- main/cli-bootstrap.ts adds an ensureManagedCli() fallback that
  downloads the latest release from GitHub when no bundled binary
  exists (first launch on a machine without developer tooling).
- daemon-manager.resolveCliBinary prefers bundled > managed > download
  > PATH, so local iteration uses the freshly built binary.

Daemon profile
- resolveActiveProfile now derives a desktop-<host> profile name from
  the target API URL and creates its config.json on demand. Never
  reads or writes the user's hand-configured CLI profiles, avoiding
  the "Desktop polluted my default profile" class of bug.
- syncToken detects a JWT input and exchanges it for a PAT via
  POST /api/tokens; caches the resulting mul_* token in the profile
  config so subsequent launches skip the round-trip.
- startDaemon / stopDaemon / log tail all operate on the resolved
  profile; renderer sets the target URL via a new
  daemon:set-target-api-url IPC.

Workspace watching
- daemon-manager exposes daemon:list-watched / daemon:watch-workspace /
  daemon:unwatch-workspace IPCs backed by the daemon's new /watch
  endpoints.
- App.tsx reconciles the user's workspace list against the daemon's
  watched set whenever TanStack Query updates it — new workspaces are
  registered instantly instead of waiting for the daemon's 30s sync,
  and removed workspaces are unwatched.
- daemon-panel gains a "Watched Workspaces" section with per-workspace
  checkboxes that call watch/unwatch directly. Opt-outs persist in the
  profile's unwatched_workspaces denylist.

Lifecycle states + UI
- DaemonStatus gains `profile`, `serverUrl`, and an `installing_cli`
  state. Panel shows Profile / Server info rows and a "Setting up…"
  blurb during first-run CLI download; failure surfaces a Retry button.
- Status bar renders a spinner during installation and hides the Start
  button until setup finishes.

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

* fix(desktop): register /onboarding route

The create-workspace modal navigates to /onboarding on success, but
the Desktop router only had flat routes (issues, projects, runtimes,
etc.) — resulting in an "Unexpected Application Error! 404 Not Found"
page after creating a new workspace.

Mirror the web app's wiring: render OnboardingWizard with onComplete
pushing to /issues, via the shared navigation adapter.

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

* refactor(desktop): remove sidebar daemon status bar

Drop the bottom-left daemon indicator in favor of the DaemonRuntimeCard
at the top of the Runtimes page, which already shows the same info
plus full Start/Stop/Restart controls and the Logs entry point. A
single canonical place avoids fragmenting daemon status across the UI.

Also remove the now-unused `bottomSlot` prop from AppSidebar — Desktop
was the only consumer, Web never needed it, so keeping it would be
dead scaffolding.

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

* fix(desktop): daemon panel layout and close button

- Logs section now fills the remaining vertical space down to the
  sheet bottom instead of being capped at h-64, which left a huge
  empty area below it. Top section (status, actions, watched list)
  keeps natural height as shrink-0; the watched list gets its own
  max-h-48 scroll so a long list can't push Logs off screen.
- Replace the Sheet's built-in close button with an explicit
  <button> wired directly to onOpenChange(false). The Base UI
  Dialog.Close wrapped in Button via the render prop wasn't firing
  on click in this panel; going straight through the controlled
  state guarantees it responds.

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

* fix(desktop): make daemon panel clickable inside Electron drag region

The sheet opens at the top of the window, which visually overlaps the
TabBar's -webkit-app-region: drag zone. Even though the sheet portals
to document.body, Chromium computes drag regions over the final
composited pixels, so the sheet inherited "drag" and swallowed the
mouseup of every click (mousedown fired but click never resolved) —
including the X close button.

Mark the entire SheetContent popup with -webkit-app-region: no-drag
to subtract it from the drag region. This also fixes future buttons /
checkboxes inside the sheet that would have hit the same issue.

While here, move the close button into the SheetHeader as a flex
sibling of SheetTitle instead of an absolutely positioned overlay —
simpler layout and avoids any stacking-context weirdness.

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

* feat(desktop): clickable daemon runtime card row

The whole Local Daemon row now opens the sheet panel — icon, title,
and status line are all part of one click target. This replaces the
standalone "Logs" button, which was redundant now that clicking
anywhere on the row does the same thing.

The right-side action cluster (Start / Stop / Restart) wraps its
onClick in stopPropagation so pressing those buttons doesn't bubble
up and open the panel.

Keyboard access: Enter / Space on the focused row opens the panel,
with a focus-visible background for feedback.

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

* feat(runtimes): mark Desktop-launched daemons as managed

When the Multica Desktop app spawns the CLI it ships with, the
resulting daemon shares its binary with the Electron bundle — Desktop
is responsible for updating that binary on every release. Letting the
daemon self-update would just get clobbered on the next Desktop launch
and could brick the embedded binary mid-update.

Propagate a "launched_by" signal end-to-end so the UI can hide the
CLI self-update affordance (and the daemon refuses updates as a second
line of defense):

- Desktop's startDaemon spawns execFile with env MULTICA_LAUNCHED_BY=desktop.
- daemon.Config gains LaunchedBy; cmd_daemon reads the env var on boot.
- registerRuntimesForWorkspace includes launched_by in the request body.
- Server DaemonRegister folds launched_by into runtime.metadata (JSONB
  — no migration needed).
- handleUpdate returns a "failed" status with an explanatory message
  when LaunchedBy == "desktop", so even a bypass API call can't trigger
  the self-update path.
- RuntimeDetail extracts metadata.launched_by and passes it to
  UpdateSection, which swaps the Latest / → available / Update button
  cluster for a muted "Managed by Desktop" label.

CLI-only users (brew install, direct tarball) keep the exact same
behavior — the env var is empty, the UI shows the update button,
the daemon still self-updates on request.

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

* fix(desktop): harden daemon manager from PR review

- syncToken now takes userId and mints a fresh PAT on user switch,
  restarting a running daemon so it picks up the new credentials.
  A .desktop-user-id sidecar in each profile records the owner so a
  previous user's cached PAT can't be reused on the next login.
- App.tsx wires onLogout on CoreProvider to daemonAPI.clearToken()
  and daemonAPI.stop() so the cached PAT and live daemon don't
  outlive the session.
- startLogTail replaced with a cross-platform watchFile
  implementation (initial 32 KB window + poll for new bytes,
  handles truncation). spawn("tail") was broken on Windows.
- writeProfileConfig now serializes through a promise chain to
  prevent concurrent writes from corrupting config.json.
- startDaemon keeps the "starting" state until pollOnce confirms
  /health, avoiding a running → stopped flash when the Go daemon
  isn't yet listening after the supervisor returns.

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

* fix(desktop): verify downloaded CLI against checksums.txt

Download goreleaser's checksums.txt alongside the release archive,
parse the sha256 lookup, stream the archive through createHash, and
refuse to install on mismatch or missing entry. Closes the supply-
chain gap where auto-install would execute an unverified binary on
first launch.

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

* chore(desktop): lint and style cleanups from PR review

- eslint.config.mjs: add scripts/**/*.{mjs,js} override with
  globals.node so bundle-cli.mjs lints clean (was erroring on
  undefined process/console).
- daemon-panel.tsx: log level classes now use semantic tokens
  (text-info, text-warning, text-destructive) instead of hardcoded
  Tailwind colors; escape the apostrophe in the retry copy.
- daemon-settings-tab.tsx: import DaemonPrefs from shared/daemon-
  types instead of redefining it.
- runtimes-page.tsx: fix indentation inside the new topSlot wrapper.

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
2026-04-14 19:12:39 +08:00
Naiyuan Qing
d3f7570177 feat(chat): skeleton while switching to an un-cached session
Switching to a session whose messages aren't cached showed the empty
state (starter prompts) for the ~300ms the fetch took — jarring, because
you're clicking into an existing conversation, not starting a new one.

Now there's a three-branch render: skeleton while loading, empty state
for real new-chat (activeSessionId === null), messages when ready.
Cached switches still return data synchronously — no flash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:49:26 +08:00
Naiyuan Qing
34e452776b fix(chat): reading-width container + refresh placeholder on agent switch
- Wrap ChatMessageList and ChatInput in mx-auto max-w-4xl px-5 so wide
  chat windows don't sprawl — matches the issue-detail / project-detail
  width convention
- draftKey now includes the selected agent id in the new-chat state.
  Tiptap's Placeholder only applies at mount, so key-driven remount is
  the simplest way to refresh it when the user switches agents before
  sending the first message. Side benefit: per-agent new-chat drafts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:42:55 +08:00
Bohan Jiang
2551aa53ef fix(docs): use light theme for Star History chart in dark mode (#992)
* docs: add Trendshift GitHub Trending badge to READMEs

Add dynamic GitHub Trending badge from Trendshift.io (repo ID 24695)
to both English and Chinese READMEs, placed below existing CI/stars
badges.

* docs: replace Trendshift badge with Star History chart

Remove the Trendshift trending badge and add a Star History chart
section at the end of both English and Chinese READMEs. The chart
supports dark/light mode and links to the interactive star-history page.

* fix(docs): use light theme for Star History chart in both color schemes

Remove &theme=dark from the dark mode source so the chart always
renders with a light background regardless of GitHub's color scheme.
2026-04-14 18:34:35 +08:00
Naiyuan Qing
d779cbd183 Merge pull request #990 from multica-ai/NevilleQingNY/fix-cmdk-stale-status
fix(views): resolve stale status in cmd+k recent issues list
2026-04-14 18:26:28 +08:00
Naiyuan Qing
10b6afc1ec Merge pull request #988 from multica-ai/feat/chat-redesign
feat(chat): redesign state, header, and unread tracking
2026-04-14 18:24:20 +08:00
Bohan Jiang
4f58f0c8eb docs: add Star History chart to READMEs (#989)
* docs: add Trendshift GitHub Trending badge to READMEs

Add dynamic GitHub Trending badge from Trendshift.io (repo ID 24695)
to both English and Chinese READMEs, placed below existing CI/stars
badges.

* docs: replace Trendshift badge with Star History chart

Remove the Trendshift trending badge and add a Star History chart
section at the end of both English and Chinese READMEs. The chart
supports dark/light mode and links to the interactive star-history page.
2026-04-14 18:24:15 +08:00
Naiyuan Qing
0399e387f8 fix(views): resolve stale status in cmd+k recent issues list
Recent issues store was duplicating server data (title, status, identifier)
in Zustand, violating the single-source-of-truth architecture. Now the store
only tracks visit records (id + visitedAt), and the search command joins
fresh data from the TanStack Query issue list cache at render time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:23:27 +08:00
Naiyuan Qing
a744cd4f45 feat(chat): redesign state, header, and unread tracking
State management
- Pending task / live timeline are now Query-cache single source;
  Zustand mirror removed (fixes duplicate assistant render caused by
  the invalidate→refetch race window)
- WS subscriptions moved from ChatWindow to global useRealtimeSync so
  pending state survives minimize and refresh
- New GET /chat/sessions/:id/pending-task to recover live state on mount
- Drafts persisted per-session (was per-workspace)

Unread tracking
- Migration 040: chat_session.unread_since (event-driven; old chats
  stay clean — no mass backfill)
- POST /chat/sessions/:id/read clears unread; broadcasts
  chat:session_read so other devices sync
- New GET /chat/pending-tasks aggregate for the FAB
- ChatFab: brand-color impulse animation while running, brand-dot
  badge of unread session count
- ChatWindow auto-marks read when user is viewing the session

Header redesign
- Two independent dropdowns: agent (avatar + name + My/Others
  grouping) at the input bottom-left; session (title + agent avatar)
  in the header
- ⊕ new-chat button replaces the old + and history buttons
- Session dropdown lists all sessions across agents with avatars
- Empty state: 3 clickable starter prompts that send immediately
- Mention link renderer falls through to default span on null —
  fixes @member/@agent/@all silently disappearing app-wide
- User messages render through Markdown
- Enter submits in chat input only (with IME guard + codeBlock skip);
  bubble menu hidden in chat

Misc
- Partial index on agent_task_queue for fast pending-task lookup
- 2 new storage keys added to clearWorkspaceStorage
- useMarkChatSessionRead has onError rollback
- chat.* namespace logs across store, mutations, components, realtime

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:21:11 +08:00
Jiayuan Zhang
bfa9bec8c4 docs: add Multica vs Paperclip comparison to README (#980) 2026-04-14 18:06:44 +08:00
Bohan Jiang
bf71802451 fix(server): trigger agent on reply in thread where agent already participated (#981)
When a member replies in a member-started thread without @mentioning the
assigned agent, the on_comment trigger was suppressed — even if the agent
had already replied in that thread. This meant the common flow of
"member posts → agent replies → member follows up" would not re-trigger
the agent on the follow-up.

Add HasAgentRepliedInThread SQL query and check it in isReplyToMemberThread
so that agent participation in a thread is treated as an ongoing conversation.
2026-04-14 18:00:29 +08:00
Jiayuan Zhang
09e6190400 fix(cli): use localhost for CLI callback when app URL is a public hostname (#977)
When `multica login` runs against production (multica.ai), the CLI was
using the app URL hostname as the callback host, producing a callback
URL like `http://multica.ai:PORT/callback`. This URL fails frontend
validation (which only allows localhost and private IPs) and can't
actually reach the CLI's local HTTP server.

Now only private IPs (RFC 1918) are used as the callback host, which
matches the intended self-hosted LAN scenario. Public hostnames
correctly fall back to localhost.

Fixes #974
2026-04-14 17:54:10 +08:00
Naiyuan Qing
0798b5f8bb Merge pull request #982 from multica-ai/agent/agent/85c11509
fix(views): prevent focus jump on modal close
2026-04-14 17:53:51 +08:00
Naiyuan Qing
e568896357 fix(views): prevent focus jumping to random button when closing store-opened modals
Add finalFocus={false} to DialogContent in create-issue and create-workspace
modals so Base UI does not attempt focus restoration on close. These modals
are always opened programmatically via useModalStore (no trigger element),
so there is no meaningful element to return focus to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:52:16 +08:00
Bohan Jiang
8748557c7b fix: clarify local vs workspace skills to reduce user confusion (#975)
* fix: clarify that local skills work automatically, workspace skills are for team sharing

Users were confused, thinking they needed to re-upload locally installed skills
(e.g. .claude/skills/) to Multica before agents could use them. Updated UI text
across the skills page, agent skills tab, onboarding, and docs to clearly
distinguish between local skills (auto-discovered) and workspace skills (for
team-wide sharing).

Closes #972

* feat(views): replace inline text with info banner for local skills hint

The "local runtime skills are always available" message was buried in
the description text and easy to miss. Move it into a visible info
callout banner with an icon so users notice it immediately.
2026-04-14 17:49:34 +08:00
85 changed files with 4290 additions and 557 deletions

View File

@@ -115,6 +115,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
| **User model** | Multi-user teams with roles & permissions | Single board operator |
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
| **Deployment** | Cloud-first | Local-first |
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
| **Extensibility** | Skills system | Skills + Plugin system |
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
@@ -169,3 +184,13 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -117,6 +117,21 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
大功告成!你的 Agent 现在是团队的一员了。 🎉
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
| **部署** | 云端优先 | 本地优先 |
| **管理深度** | 轻量Issue / Project / Labels | 重度(组织架构 / 审批 / 预算) |
| **扩展** | Skills 系统 | Skills + 插件系统 |
**简单来说Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
## 架构
```
@@ -157,3 +172,13 @@ make start
## 开源协议
[Apache 2.0](LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -4,3 +4,5 @@ out
.DS_Store
.eslintcache
*.log*
# CLI binary bundled at build time (from server/bin/)
resources/bin/

View File

@@ -1,41 +1,26 @@
import { resolve } from "path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import { loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const remoteApi = env.VITE_REMOTE_API;
const remoteWs = remoteApi?.replace(/^https/, "wss").replace(/^http/, "ws");
return {
main: {
plugins: [externalizeDepsPlugin()],
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
...(remoteApi && {
proxy: {
"/api": { target: remoteApi, changeOrigin: true },
"/auth": { target: remoteApi, changeOrigin: true },
"/uploads": { target: remoteApi, changeOrigin: true },
"/ws": { target: remoteWs, changeOrigin: true, ws: true },
},
}),
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
},
};
},
});

View File

@@ -1,6 +1,13 @@
import globals from "globals";
import reactConfig from "@multica/eslint-config/react";
export default [
...reactConfig,
{ ignores: ["out/", "dist/"] },
{
files: ["scripts/**/*.{mjs,js}"],
languageOptions: {
globals: { ...globals.node },
},
},
];

View File

@@ -4,14 +4,14 @@
"private": true,
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:remote": "electron-vite dev --mode remote",
"build": "electron-vite build",
"bundle-cli": "node scripts/bundle-cli.mjs",
"dev": "pnpm run bundle-cli && electron-vite dev",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"preview": "electron-vite preview",
"package": "electron-builder",
"package": "pnpm run bundle-cli && electron-builder",
"lint": "eslint .",
"postinstall": "electron-builder install-app-deps"
},

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env node
// Builds the `multica` CLI from server/cmd/multica and copies the binary
// into apps/desktop/resources/bin/ so electron-vite (dev) and electron-
// builder (prod) pick it up. Running this on every dev/build/package
// invocation guarantees the bundled CLI always matches the current Go
// source — no more stale binary surprises. Go's build cache makes the
// no-op case (nothing changed) effectively free.
//
// ldflags mirror `make build` so `multica --version` reports a meaningful
// version / commit / date.
//
// Graceful: if `go` is not installed (e.g. frontend-only contributor), we
// skip the build and fall through to auto-install at runtime. A genuine
// Go compile error is fatal — you want that to block dev, not hide.
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
import { constants } from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..", "..");
const serverDir = join(repoRoot, "server");
const binName = process.platform === "win32" ? "multica.exe" : "multica";
const srcBinary = join(serverDir, "bin", binName);
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
const destBinary = join(destDir, binName);
function sh(cmd) {
try {
return execSync(cmd, { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
function hasGo() {
try {
execSync("go version", { stdio: "pipe" });
return true;
} catch {
return false;
}
}
async function exists(p) {
try {
await access(p, constants.F_OK);
return true;
} catch {
return false;
}
}
if (hasGo()) {
const version = sh("git describe --tags --always --dirty") || "dev";
const commit = sh("git rev-parse --short HEAD") || "unknown";
const date = new Date().toISOString().replace(/\.\d+Z$/, "Z");
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
console.log(
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
);
execFileSync(
"go",
[
"build",
"-ldflags",
ldflags,
"-o",
join("bin", binName),
"./cmd/multica",
],
{ cwd: serverDir, stdio: "inherit" },
);
} else {
console.warn(
"[bundle-cli] `go` not found in PATH — skipping CLI build. " +
"Desktop will use whatever is already in resources/bin/, or fall back " +
"to auto-installing the latest release at runtime.",
);
}
if (!(await exists(srcBinary))) {
console.warn(
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
`auto-installing the latest release at runtime.`,
);
process.exit(0);
}
await mkdir(destDir, { recursive: true });
await copyFile(srcBinary, destBinary);
await chmod(destBinary, 0o755);
// macOS: ad-hoc sign so Gatekeeper doesn't complain when the parent app
// (which itself may be unsigned in dev) spawns the child.
if (process.platform === "darwin") {
try {
execSync(`codesign -s - --force ${JSON.stringify(destBinary)}`, {
stdio: "pipe",
});
} catch {
// Non-fatal. Unsigned binaries still run when the parent app is trusted.
}
}
console.log(`[bundle-cli] bundled ${srcBinary}${destBinary}`);

View File

@@ -0,0 +1,173 @@
import { app } from "electron";
import { execFile } from "child_process";
import { createHash } from "crypto";
import { createReadStream, createWriteStream, existsSync } from "fs";
import { chmod, mkdir, rename, rm } from "fs/promises";
import { join, dirname } from "path";
import { pipeline } from "stream/promises";
import { tmpdir } from "os";
import { Readable } from "stream";
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
// launch, so users never have to brew-install anything. Build-time decoupled:
// we don't bundle the binary into the .app, we download whatever the upstream
// release is at first run.
const GITHUB_LATEST_BASE =
"https://github.com/multica-ai/multica/releases/latest/download";
function platformAssetName(): string {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[process.platform];
const arch = archMap[process.arch];
if (!os || !arch) {
throw new Error(
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
);
}
const ext = process.platform === "win32" ? "zip" : "tar.gz";
return `multica_${os}_${arch}.${ext}`;
}
function binaryName(): string {
return process.platform === "win32" ? "multica.exe" : "multica";
}
export function managedCliPath(): string {
return join(app.getPath("userData"), "bin", binaryName());
}
function run(cmd: string, args: string[], cwd?: string): Promise<void> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { cwd }, (err) => (err ? reject(err) : resolve()));
});
}
async function downloadToFile(url: string, dest: string): Promise<void> {
const res = await fetch(url, { redirect: "follow" });
if (!res.ok || !res.body) {
throw new Error(`download failed: ${res.status} ${res.statusText}`);
}
await mkdir(dirname(dest), { recursive: true });
// Node's fetch returns a web ReadableStream; adapt to a Node stream for pipeline.
const nodeStream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
await pipeline(nodeStream, createWriteStream(dest));
}
// Fetch goreleaser's published checksums.txt and parse it into a
// filename → sha256 lookup. Format is `<hex> <filename>` per line.
async function fetchChecksums(): Promise<Map<string, string>> {
const url = `${GITHUB_LATEST_BASE}/checksums.txt`;
const res = await fetch(url, { redirect: "follow" });
if (!res.ok) {
throw new Error(
`checksums.txt fetch failed: ${res.status} ${res.statusText}`,
);
}
const text = await res.text();
const map = new Map<string, string>();
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const match = line.match(/^([a-f0-9]{64})\s+\*?(\S+)$/i);
if (match) map.set(match[2], match[1].toLowerCase());
}
return map;
}
async function sha256OfFile(path: string): Promise<string> {
const hash = createHash("sha256");
await pipeline(createReadStream(path), hash);
return hash.digest("hex");
}
async function verifyChecksum(
archivePath: string,
assetName: string,
): Promise<void> {
const checksums = await fetchChecksums();
const expected = checksums.get(assetName);
if (!expected) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const actual = await sha256OfFile(archivePath);
if (actual.toLowerCase() !== expected) {
throw new Error(
`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
}
async function extractArchive(archive: string, dest: string): Promise<void> {
await mkdir(dest, { recursive: true });
// Modern OSes all ship a `tar` that auto-detects tar.gz and zip:
// - macOS/Linux: GNU tar or bsdtar
// - Windows 10+: bsdtar is bundled as `tar.exe` since build 17063
await run("tar", ["-xf", archive, "-C", dest]);
}
async function installFresh(): Promise<string> {
const target = managedCliPath();
const assetName = platformAssetName();
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
await mkdir(workDir, { recursive: true });
try {
const archivePath = join(workDir, assetName);
console.log(`[cli-bootstrap] downloading ${url}`);
await downloadToFile(url, archivePath);
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
await verifyChecksum(archivePath, assetName);
console.log(`[cli-bootstrap] extracting ${assetName}`);
await extractArchive(archivePath, workDir);
const extractedBin = join(workDir, binaryName());
if (!existsSync(extractedBin)) {
throw new Error(
`archive ${assetName} did not contain ${binaryName()} at its root`,
);
}
await mkdir(dirname(target), { recursive: true });
await rename(extractedBin, target);
await chmod(target, 0o755);
// macOS: ad-hoc sign so spawning the child never hits a gatekeeper quirk.
// Non-fatal: unsigned binaries still execute when the parent app is trusted.
if (process.platform === "darwin") {
await run("codesign", ["-s", "-", "--force", target]).catch((err) => {
console.warn("[cli-bootstrap] ad-hoc codesign failed:", err);
});
}
console.log(`[cli-bootstrap] installed CLI at ${target}`);
return target;
} finally {
await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Returns the path to a usable `multica` binary. If one is already present at
* the managed userData location, returns it immediately. Otherwise downloads
* the latest release asset for the current platform and installs it.
*/
export async function ensureManagedCli(): Promise<string> {
const target = managedCliPath();
if (existsSync(target)) return target;
return installFresh();
}

View File

@@ -0,0 +1,877 @@
import { app, ipcMain, BrowserWindow } from "electron";
import { execFile } from "child_process";
import {
readFile,
writeFile,
mkdir,
rm,
open,
stat,
} from "fs/promises";
import {
existsSync,
watchFile,
unwatchFile,
type StatsListener,
} from "fs";
import { join } from "path";
import { homedir } from "os";
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
const DEFAULT_HEALTH_PORT = 19514;
const POLL_INTERVAL_MS = 5_000;
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
const LOG_TAIL_RETRY_MS = 2_000;
const LOG_TAIL_MAX_RETRIES = 5;
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
interface ActiveProfile {
name: string; // "" = default profile
port: number;
}
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
let logTailWatcher: { path: string; listener: StatsListener } | null = null;
let currentState: DaemonStatus["state"] = "installing_cli";
let getMainWindow: () => BrowserWindow | null = () => null;
let operationInProgress = false;
let cachedCliBinary: string | null | undefined = undefined;
let cliResolvePromise: Promise<string | null> | null = null;
let targetApiBaseUrl: string | null = null;
let activeProfile: ActiveProfile | null = null;
// Serialize all writes to any profile config file. Multiple paths
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
// may try to write concurrently; chaining them avoids interleaved writes
// corrupting the JSON.
let configWriteChain: Promise<void> = Promise.resolve();
// Keep the Go impl in sync: server/cmd/multica/cmd_daemon.go healthPortForProfile.
function healthPortForProfile(profile: string): number {
if (!profile) return DEFAULT_HEALTH_PORT;
let sum = 0;
for (const b of Buffer.from(profile, "utf-8")) sum += b;
return DEFAULT_HEALTH_PORT + 1 + (sum % 1000);
}
function profileDir(profile: string): string {
return profile
? join(homedir(), ".multica", "profiles", profile)
: join(homedir(), ".multica");
}
function profileConfigPath(profile: string): string {
return join(profileDir(profile), "config.json");
}
function profileLogPath(profile: string): string {
return join(profileDir(profile), "daemon.log");
}
// Sidecar file that records which Multica user the cached PAT in config.json
// was minted for. The Go CLI/daemon never read or write this file, so it
// survives Go-side config rewrites. Used to detect user switches and mint a
// fresh PAT instead of reusing a token that belongs to a previous user.
function profileUserIdPath(profile: string): string {
return join(profileDir(profile), ".desktop-user-id");
}
async function readProfileUserId(profile: string): Promise<string | null> {
try {
const raw = await readFile(profileUserIdPath(profile), "utf-8");
const trimmed = raw.trim();
return trimmed || null;
} catch {
return null;
}
}
async function writeProfileUserId(
profile: string,
userId: string,
): Promise<void> {
await mkdir(profileDir(profile), { recursive: true });
await writeFile(profileUserIdPath(profile), userId, "utf-8");
}
async function removeProfileUserId(profile: string): Promise<void> {
try {
await rm(profileUserIdPath(profile));
} catch {
// Already gone — nothing to do.
}
}
function normalizeUrl(u: string): string {
if (!u) return "";
try {
const parsed = new URL(u);
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
} catch {
return u.replace(/\/+$/, "").toLowerCase();
}
}
function urlsMatch(a: string, b: string): boolean {
const na = normalizeUrl(a);
const nb = normalizeUrl(b);
return na.length > 0 && na === nb;
}
function sendStatus(status: DaemonStatus): void {
const win = getMainWindow();
win?.webContents.send("daemon:status", status);
}
interface HealthPayload {
status?: string;
pid?: number;
uptime?: string;
daemon_id?: string;
device_name?: string;
server_url?: string;
agents?: string[];
workspaces?: unknown[];
}
async function fetchHealthAtPort(
port: number,
): Promise<HealthPayload | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2_000);
const res = await fetch(`http://127.0.0.1:${port}/health`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) return null;
return (await res.json()) as HealthPayload;
} catch {
return null;
}
}
// Desktop owns a dedicated CLI profile named after the target API host, so it
// never reads or writes the user's hand-configured profiles. Profile dir:
// ~/.multica/profiles/desktop-<host>/
function deriveProfileName(targetUrl: string): string {
try {
const url = new URL(targetUrl);
const host = url.host.replace(/:/g, "-").toLowerCase();
return `desktop-${host}`;
} catch {
return "desktop";
}
}
async function readProfileConfig(
profile: string,
): Promise<Record<string, unknown>> {
try {
const raw = await readFile(profileConfigPath(profile), "utf-8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
async function writeProfileConfig(
profile: string,
cfg: Record<string, unknown>,
): Promise<void> {
const op = async () => {
await mkdir(profileDir(profile), { recursive: true });
await writeFile(
profileConfigPath(profile),
JSON.stringify(cfg, null, 2),
"utf-8",
);
};
const next = configWriteChain.catch(() => {}).then(op);
configWriteChain = next.catch(() => {});
return next;
}
/**
* Returns the Desktop-owned profile for the current target API URL. Creates
* the profile's config.json on demand with `server_url` pinned to the target.
*
* This function never falls back to the default profile, and never touches a
* profile whose name doesn't start with `desktop-`, so the user's manually
* configured CLI profiles are untouched.
*/
async function resolveActiveProfile(): Promise<ActiveProfile> {
const target = targetApiBaseUrl;
if (!target) return { name: "", port: DEFAULT_HEALTH_PORT };
const name = deriveProfileName(target);
const cfg = await readProfileConfig(name);
if (cfg.server_url !== target) {
cfg.server_url = target;
await writeProfileConfig(name, cfg);
console.log(`[daemon] initialized profile "${name}" → ${target}`);
}
return { name, port: healthPortForProfile(name) };
}
async function ensureActiveProfile(): Promise<ActiveProfile> {
if (activeProfile) return activeProfile;
activeProfile = await resolveActiveProfile();
return activeProfile;
}
function invalidateActiveProfile(): void {
activeProfile = null;
}
async function fetchHealth(): Promise<DaemonStatus> {
// While the CLI is being downloaded or has permanently failed, short-circuit
// polling — there's nothing to probe yet and /health calls would just return
// "stopped", which would overwrite the correct setup state in the UI.
if (currentState === "installing_cli" || currentState === "cli_not_found") {
return { state: currentState };
}
const active = await ensureActiveProfile();
const data = await fetchHealthAtPort(active.port);
if (!data || data.status !== "running") {
return {
state: currentState === "starting" ? "starting" : "stopped",
profile: active.name,
};
}
// Safety: if we have a target URL and the daemon on our port reports a
// different server_url, it's not "our" daemon — drop it and re-resolve.
if (
targetApiBaseUrl &&
data.server_url &&
!urlsMatch(data.server_url, targetApiBaseUrl)
) {
invalidateActiveProfile();
return { state: "stopped" };
}
return {
state: "running",
pid: data.pid,
uptime: data.uptime,
daemonId: data.daemon_id,
deviceName: data.device_name,
agents: data.agents ?? [],
workspaceCount: Array.isArray(data.workspaces)
? data.workspaces.length
: 0,
profile: active.name,
serverUrl: data.server_url,
};
}
function findCliOnPath(): string | null {
const candidates = process.platform === "win32" ? ["multica.exe"] : ["multica"];
const paths = (process.env["PATH"] ?? "").split(
process.platform === "win32" ? ";" : ":",
);
if (process.platform === "darwin") {
paths.push("/opt/homebrew/bin", "/usr/local/bin");
}
for (const name of candidates) {
for (const dir of paths) {
const full = join(dir, name);
if (existsSync(full)) return full;
}
}
return null;
}
/**
* Returns the path to the CLI binary bundled inside the Desktop app.
*
* - Dev (`electron-vite dev`): `app.getAppPath()` → `apps/desktop`, resolving
* to `apps/desktop/resources/bin/multica`. `bundle-cli.mjs` populates this
* before dev starts, so iterating on Go changes is "make build → restart".
* - Packaged: `app.getAppPath()` → `<Multica.app>/Contents/Resources/app.asar`.
* electron-builder's `asarUnpack: resources/**` extracts the binary to
* `app.asar.unpacked/`, so we swap the path segment to execute it.
*/
function bundledCliPath(): string {
const binName = process.platform === "win32" ? "multica.exe" : "multica";
return join(app.getAppPath(), "resources", "bin", binName).replace(
"app.asar",
"app.asar.unpacked",
);
}
/**
* Returns a usable `multica` binary path. Priority:
* 1. Cached result from a previous successful resolve.
* 2. Bundled binary shipped with the Desktop app (`bundle-cli.mjs`).
* 3. Managed binary already installed in userData (`managedCliPath`).
* 4. Download + install latest release into userData.
* 5. `multica` on PATH (dev convenience / user-installed via brew).
* Returns `null` only when all of the above fail.
*
* Bundled is preferred so Desktop iterates in lockstep with Go changes in
* the same repo — avoids the 404 / stale-API problem when the Desktop's
* TS side is ahead of the last published CLI release.
*
* This function is idempotent and safe to call concurrently — in-flight
* installs are de-duplicated via `cliResolvePromise`.
*/
async function resolveCliBinary(): Promise<string | null> {
if (cachedCliBinary !== undefined) return cachedCliBinary;
if (cliResolvePromise) return cliResolvePromise;
cliResolvePromise = (async () => {
const bundled = bundledCliPath();
if (existsSync(bundled)) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
return bundled;
}
const managed = managedCliPath();
if (existsSync(managed)) {
cachedCliBinary = managed;
return managed;
}
try {
const installed = await ensureManagedCli();
cachedCliBinary = installed;
return installed;
} catch (err) {
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
const onPath = findCliOnPath();
cachedCliBinary = onPath;
return onPath;
}
})();
try {
return await cliResolvePromise;
} finally {
cliResolvePromise = null;
}
}
/**
* Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The
* daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30
* days and signatures are tied to a specific backend instance.
*/
async function mintPat(jwt: string): Promise<string> {
if (!targetApiBaseUrl) {
throw new Error("mint PAT: target API URL not set");
}
const url = `${targetApiBaseUrl.replace(/\/+$/, "")}/api/tokens`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
// Omit expires_in_days → server treats as null → non-expiring PAT.
body: JSON.stringify({ name: "Multica Desktop" }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
}
const data = (await res.json()) as { token?: unknown };
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
throw new Error("mint PAT: response missing token");
}
return data.token;
}
/**
* Ensure the active profile's config.json has a usable token for the daemon.
*
* - Input from the renderer is the user's JWT (from localStorage) plus the
* current user's id, so we can detect session changes.
* - If the profile already has a cached PAT (`mul_...`) AND the sidecar user
* id matches the caller, reuse it — minting fresh on every launch would
* accumulate garbage in the user's tokens page.
* - On user mismatch (or first run) call POST /api/tokens with the JWT to
* mint a fresh PAT, overwriting any stale cached PAT. This is the critical
* path: without it, a previous user's PAT would be used by a new session.
* - If the caller happens to pass a PAT directly, write it through.
* - When we mint fresh and a daemon is already running, restart it so the
* new credentials take effect (the Go daemon reads config at startup).
*/
async function syncToken(
tokenFromRenderer: string,
userId: string,
): Promise<void> {
const active = await ensureActiveProfile();
const config = await readProfileConfig(active.name);
const previousUserId = await readProfileUserId(active.name);
const userChanged = Boolean(previousUserId) && previousUserId !== userId;
const sameUserWithCachedPat =
!userChanged &&
previousUserId === userId &&
typeof config.token === "string" &&
config.token.startsWith("mul_");
let finalToken: string;
if (tokenFromRenderer.startsWith("mul_")) {
finalToken = tokenFromRenderer;
} else if (sameUserWithCachedPat) {
finalToken = config.token as string;
} else {
try {
finalToken = await mintPat(tokenFromRenderer);
console.log(
`[daemon] minted PAT for profile "${active.name}" (user_changed=${userChanged})`,
);
} catch (err) {
console.error("[daemon] failed to mint PAT:", err);
throw err;
}
}
config.token = finalToken;
if (targetApiBaseUrl) config.server_url = targetApiBaseUrl;
await writeProfileConfig(active.name, config);
await writeProfileUserId(active.name, userId);
// If we just rotated credentials onto a running daemon, restart it so the
// in-memory token in the Go process matches the new config.
if (userChanged) {
try {
const existing = await fetchHealthAtPort(active.port);
if (existing?.status === "running") {
console.log(
"[daemon] user switched — restarting daemon with new credentials",
);
void restartDaemon();
}
} catch (err) {
console.warn("[daemon] restart-on-user-switch failed:", err);
}
}
}
async function loadPrefs(): Promise<DaemonPrefs> {
try {
const raw = await readFile(PREFS_PATH, "utf-8");
const parsed = JSON.parse(raw);
return { ...DEFAULT_PREFS, ...parsed };
} catch {
return { ...DEFAULT_PREFS };
}
}
async function savePrefs(prefs: DaemonPrefs): Promise<void> {
const dir = join(homedir(), ".multica");
await mkdir(dir, { recursive: true });
await writeFile(PREFS_PATH, JSON.stringify(prefs, null, 2), "utf-8");
}
async function clearToken(): Promise<void> {
const active = await ensureActiveProfile();
const config = await readProfileConfig(active.name);
if ("token" in config) {
delete config.token;
await writeProfileConfig(active.name, config);
}
// Always drop the sidecar so a subsequent syncToken from any user is
// treated as a fresh mint, not a reuse of a stale cached PAT.
await removeProfileUserId(active.name);
}
interface WatchedWorkspace {
id: string;
name: string;
runtime_count?: number;
}
interface WatchListResponse {
watched: WatchedWorkspace[];
unwatched: string[];
}
async function daemonFetch(
path: string,
init?: RequestInit,
): Promise<Response> {
const active = await ensureActiveProfile();
const url = `http://127.0.0.1:${active.port}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
async function listWatchedWorkspaces(): Promise<WatchListResponse> {
const empty: WatchListResponse = { watched: [], unwatched: [] };
try {
const res = await daemonFetch("/watch");
if (!res.ok) {
// Older daemon versions don't have /watch. Treat as "nothing watched
// yet" so the UI renders cleanly; the user can take manual action.
if (res.status === 404) return empty;
throw new Error(`list /watch failed: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as WatchListResponse;
return {
watched: Array.isArray(data.watched) ? data.watched : [],
unwatched: Array.isArray(data.unwatched) ? data.unwatched : [],
};
} catch (err) {
// Network errors (ECONNREFUSED when daemon is still starting, etc.) are
// expected during startup races — return empty instead of throwing so
// we don't spam the main-process error log on every poll.
const msg = err instanceof Error ? err.message : String(err);
console.log(`[daemon] list /watch unreachable: ${msg}`);
return empty;
}
}
async function watchWorkspace(id: string, name: string): Promise<void> {
const res = await daemonFetch("/watch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspace_id: id, name }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`watch failed: ${res.status} ${body}`);
}
}
async function unwatchWorkspace(id: string): Promise<void> {
const res = await daemonFetch(`/watch/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`unwatch failed: ${res.status} ${body}`);
}
}
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
if (operationInProgress) {
return { success: false, error: "Another daemon operation is in progress" };
}
operationInProgress = true;
try {
return await fn();
} finally {
operationInProgress = false;
}
}
function profileArgs(active: ActiveProfile): string[] {
return active.name ? ["--profile", active.name] : [];
}
// Env passed to every CLI child so the daemon process knows it was spawned
// by the Desktop app. The server uses this to mark runtimes as managed and
// hide CLI self-update UI.
const DESKTOP_SPAWN_ENV = {
...process.env,
MULTICA_LAUNCHED_BY: "desktop",
};
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
const bin = await resolveCliBinary();
if (!bin) return { success: false, error: "multica CLI is not installed" };
const active = await ensureActiveProfile();
const existing = await fetchHealthAtPort(active.port);
if (existing?.status === "running") {
pollOnce();
return { success: true };
}
currentState = "starting";
sendStatus({ state: "starting" });
const args = ["daemon", "start", ...profileArgs(active)];
return new Promise((resolve) => {
execFile(
bin,
args,
{ timeout: 20_000, env: DESKTOP_SPAWN_ENV },
(err) => {
if (err) {
currentState = "stopped";
sendStatus({ state: "stopped" });
resolve({ success: false, error: err.message });
return;
}
// Stay in "starting" until pollOnce confirms /health — the CLI
// returning 0 only means the supervisor was spawned, not that the
// daemon process is already listening.
pollOnce();
resolve({ success: true });
},
);
});
}
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
const bin = await resolveCliBinary();
if (!bin) return { success: false, error: "multica CLI is not installed" };
const active = await ensureActiveProfile();
currentState = "stopping";
sendStatus({ state: "stopping" });
const args = ["daemon", "stop", ...profileArgs(active)];
return new Promise((resolve) => {
execFile(bin, args, { timeout: 15_000 }, (err) => {
if (err) {
resolve({ success: false, error: err.message });
} else {
resolve({ success: true });
}
currentState = "stopped";
sendStatus({ state: "stopped" });
});
});
}
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
const stopResult = await stopDaemon();
if (!stopResult.success) return stopResult;
return startDaemon();
}
async function pollOnce(): Promise<void> {
const status = await fetchHealth();
currentState = status.state;
sendStatus(status);
}
function startPolling(): void {
if (statusPollTimer) return;
pollOnce();
statusPollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
}
/**
* Ensures the CLI binary is available, then transitions into the normal
* stopped/running state machine. Called once at startup and again on
* user-triggered `daemon:retry-install`.
*/
async function bootstrapCli(): Promise<void> {
const bin = await resolveCliBinary();
if (!bin) {
currentState = "cli_not_found";
sendStatus({ state: "cli_not_found" });
return;
}
currentState = "stopped";
sendStatus({ state: "stopped" });
startPolling();
}
function stopPolling(): void {
if (statusPollTimer) {
clearInterval(statusPollTimer);
statusPollTimer = null;
}
}
const LOG_TAIL_INITIAL_WINDOW_BYTES = 32 * 1024;
const LOG_TAIL_INITIAL_LINES = 200;
const LOG_TAIL_POLL_MS = 500;
async function readLogRange(
path: string,
startAt: number,
length: number,
): Promise<string> {
const handle = await open(path, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, startAt);
return buffer.subarray(0, bytesRead).toString("utf-8");
} finally {
await handle.close();
}
}
function sendLines(win: BrowserWindow, text: string): void {
const lines = text.split("\n").filter((line) => line.length > 0);
for (const line of lines) {
win.webContents.send("daemon:log-line", line);
}
}
// Cross-platform tail -f replacement: read the tail of the file once, then
// poll its stat with fs.watchFile and forward any new bytes since the last
// known offset. watchFile works on macOS, Linux, and Windows; spawn("tail")
// would silently fail on Windows.
function startLogTail(win: BrowserWindow, retryCount = 0): void {
stopLogTail();
void ensureActiveProfile().then(async (active) => {
const logPath = profileLogPath(active.name);
if (!existsSync(logPath)) {
if (retryCount < LOG_TAIL_MAX_RETRIES) {
setTimeout(() => startLogTail(win, retryCount + 1), LOG_TAIL_RETRY_MS);
}
return;
}
let position = 0;
try {
const initialStats = await stat(logPath);
const windowBytes = Math.min(
initialStats.size,
LOG_TAIL_INITIAL_WINDOW_BYTES,
);
const startAt = initialStats.size - windowBytes;
if (windowBytes > 0) {
const text = await readLogRange(logPath, startAt, windowBytes);
const lines = text
.split("\n")
.filter((line) => line.length > 0)
.slice(-LOG_TAIL_INITIAL_LINES);
for (const line of lines) {
win.webContents.send("daemon:log-line", line);
}
}
position = initialStats.size;
} catch (err) {
console.warn("[daemon] log tail initial read failed:", err);
return;
}
const listener: StatsListener = (curr) => {
const target = getMainWindow();
if (!target) return;
// File rotated/truncated — restart from the new beginning.
if (curr.size < position) position = 0;
if (curr.size === position) return;
const from = position;
const length = curr.size - from;
position = curr.size;
readLogRange(logPath, from, length)
.then((text) => sendLines(target, text))
.catch((err) => {
console.warn("[daemon] log tail read failed:", err);
});
};
watchFile(logPath, { interval: LOG_TAIL_POLL_MS }, listener);
logTailWatcher = { path: logPath, listener };
});
}
function stopLogTail(): void {
if (logTailWatcher) {
unwatchFile(logTailWatcher.path, logTailWatcher.listener);
logTailWatcher = null;
}
}
export function setupDaemonManager(
windowGetter: () => BrowserWindow | null,
): void {
getMainWindow = windowGetter;
ipcMain.handle("daemon:set-target-api-url", async (_e, url: string) => {
const normalized = url || null;
if (targetApiBaseUrl !== normalized) {
console.log(`[daemon] target API URL set to ${normalized ?? "(none)"}`);
targetApiBaseUrl = normalized;
invalidateActiveProfile();
await pollOnce();
}
});
ipcMain.handle("daemon:start", () => withGuard(() => startDaemon()));
ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon()));
ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon()));
ipcMain.handle("daemon:get-status", () => fetchHealth());
ipcMain.handle(
"daemon:sync-token",
(_event, token: string, userId: string) => syncToken(token, userId),
);
ipcMain.handle("daemon:clear-token", () => clearToken());
ipcMain.handle("daemon:list-watched", () => listWatchedWorkspaces());
ipcMain.handle(
"daemon:watch-workspace",
(_event, id: string, name: string) => watchWorkspace(id, name),
);
ipcMain.handle("daemon:unwatch-workspace", (_event, id: string) =>
unwatchWorkspace(id),
);
ipcMain.handle("daemon:is-cli-installed", async () => {
const bin = await resolveCliBinary();
return bin !== null;
});
ipcMain.handle("daemon:retry-install", async () => {
cachedCliBinary = undefined;
cliResolvePromise = null;
await bootstrapCli();
});
ipcMain.handle("daemon:get-prefs", () => loadPrefs());
ipcMain.handle(
"daemon:set-prefs",
(_event, prefs: Partial<DaemonPrefs>) =>
loadPrefs().then((cur) => {
const merged = { ...cur, ...prefs };
return savePrefs(merged).then(() => merged);
}),
);
ipcMain.handle("daemon:auto-start", async () => {
const prefs = await loadPrefs();
if (!prefs.autoStart) return;
const bin = await resolveCliBinary();
if (!bin) return;
const health = await fetchHealth();
if (health.state === "running") return;
await startDaemon();
});
ipcMain.on("daemon:start-log-stream", () => {
const win = getMainWindow();
if (win) startLogTail(win);
});
ipcMain.on("daemon:stop-log-stream", () => {
stopLogTail();
});
// First-run CLI install kicks off here. Status bar shows "Setting up…"
// until the managed binary is on disk (instant on subsequent launches).
currentState = "installing_cli";
sendStatus({ state: "installing_cli" });
void bootstrapCli();
let isQuitting = false;
app.on("before-quit", (event) => {
if (isQuitting) return;
stopPolling();
stopLogTail();
loadPrefs().then(async (prefs) => {
if (prefs.autoStop) {
isQuitting = true;
event.preventDefault();
try {
await stopDaemon();
} catch {
// Best-effort stop on quit
}
app.quit();
}
});
});
}

View File

@@ -2,6 +2,7 @@ import { app, shell, BrowserWindow, ipcMain } from "electron";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
const PROTOCOL = "multica";
@@ -113,9 +114,18 @@ if (!gotTheLock) {
return shell.openExternal(url);
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (create-workspace, onboarding) can place UI in the top-left corner
// without fighting the native window controls' hit-test.
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
if (process.platform !== "darwin") return;
mainWindow?.setWindowButtonVisibility(!immersive);
});
createWindow();
setupAutoUpdater(() => mainWindow);
setupDaemonManager(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {

View File

@@ -5,6 +5,50 @@ interface DesktopAPI {
onAuthToken: (callback: (token: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
}
interface DaemonStatus {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
profile?: string;
serverUrl?: string;
}
interface DaemonPrefs {
autoStart: boolean;
autoStop: boolean;
}
interface DaemonAPI {
start: () => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
restart: () => Promise<{ success: boolean; error?: string }>;
getStatus: () => Promise<DaemonStatus>;
onStatusChange: (callback: (status: DaemonStatus) => void) => () => void;
setTargetApiUrl: (url: string) => Promise<void>;
syncToken: (token: string, userId: string) => Promise<void>;
clearToken: () => Promise<void>;
listWatched: () => Promise<{
watched: Array<{ id: string; name: string; runtime_count?: number }>;
unwatched: string[];
}>;
watchWorkspace: (id: string, name: string) => Promise<void>;
unwatchWorkspace: (id: string) => Promise<void>;
isCliInstalled: () => Promise<boolean>;
getPrefs: () => Promise<DaemonPrefs>;
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;
autoStart: () => Promise<void>;
retryInstall: () => Promise<void>;
startLogStream: () => void;
stopLogStream: () => void;
onLogLine: (callback: (line: string) => void) => () => void;
}
interface UpdaterAPI {
@@ -19,6 +63,7 @@ declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
daemonAPI: DaemonAPI;
updater: UpdaterAPI;
}
}

View File

@@ -13,6 +13,68 @@ const desktopAPI = {
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
};
interface DaemonStatus {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
profile?: string;
serverUrl?: string;
}
const daemonAPI = {
start: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:start"),
stop: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:stop"),
restart: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:restart"),
getStatus: (): Promise<DaemonStatus> =>
ipcRenderer.invoke("daemon:get-status"),
onStatusChange: (callback: (status: DaemonStatus) => void) => {
const handler = (_: unknown, status: DaemonStatus) => callback(status);
ipcRenderer.on("daemon:status", handler);
return () => ipcRenderer.removeListener("daemon:status", handler);
},
setTargetApiUrl: (url: string): Promise<void> =>
ipcRenderer.invoke("daemon:set-target-api-url", url),
syncToken: (token: string, userId: string): Promise<void> =>
ipcRenderer.invoke("daemon:sync-token", token, userId),
clearToken: (): Promise<void> =>
ipcRenderer.invoke("daemon:clear-token"),
listWatched: (): Promise<{
watched: Array<{ id: string; name: string; runtime_count?: number }>;
unwatched: string[];
}> => ipcRenderer.invoke("daemon:list-watched"),
watchWorkspace: (id: string, name: string): Promise<void> =>
ipcRenderer.invoke("daemon:watch-workspace", id, name),
unwatchWorkspace: (id: string): Promise<void> =>
ipcRenderer.invoke("daemon:unwatch-workspace", id),
isCliInstalled: (): Promise<boolean> =>
ipcRenderer.invoke("daemon:is-cli-installed"),
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>
ipcRenderer.invoke("daemon:get-prefs"),
setPrefs: (prefs: Partial<{ autoStart: boolean; autoStop: boolean }>): Promise<{ autoStart: boolean; autoStop: boolean }> =>
ipcRenderer.invoke("daemon:set-prefs", prefs),
autoStart: (): Promise<void> =>
ipcRenderer.invoke("daemon:auto-start"),
retryInstall: (): Promise<void> =>
ipcRenderer.invoke("daemon:retry-install"),
startLogStream: () => ipcRenderer.send("daemon:start-log-stream"),
stopLogStream: () => ipcRenderer.send("daemon:stop-log-stream"),
onLogLine: (callback: (line: string) => void) => {
const handler = (_: unknown, line: string) => callback(line);
ipcRenderer.on("daemon:log-line", handler);
return () => ipcRenderer.removeListener("daemon:log-line", handler);
},
};
const updaterAPI = {
@@ -38,6 +100,7 @@ const updaterAPI = {
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
contextBridge.exposeInMainWorld("daemonAPI", daemonAPI);
contextBridge.exposeInMainWorld("updater", updaterAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
@@ -45,5 +108,7 @@ if (process.contextIsolated) {
// @ts-expect-error - fallback for non-isolated context
window.desktopAPI = desktopAPI;
// @ts-expect-error - fallback for non-isolated context
window.daemonAPI = daemonAPI;
// @ts-expect-error - fallback for non-isolated context
window.updater = updaterAPI;
}

View File

@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useWorkspaceStore, workspaceListOptions } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
@@ -13,12 +14,33 @@ import { UpdateNotification } from "./components/update-notification";
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const [daemonRunning, setDaemonRunning] = useState(false);
// Tell the main process which backend URL we talk to, so daemon-manager
// can pick the matching CLI profile (server_url from ~/.multica config).
useEffect(() => {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Track daemon lifecycle so workspace reconciliation only runs when the
// daemon is actually listening — avoids a startup race where the reconcile
// effect fires before autoStart has spawned the child process.
useEffect(() => {
const unsub = window.daemonAPI.onStatusChange((s) => {
setDaemonRunning(s.state === "running");
});
window.daemonAPI.getStatus().then((s) => {
setDaemonRunning(s.state === "running");
});
return unsub;
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
useEffect(() => {
return window.desktopAPI.onAuthToken(async (token) => {
try {
await useAuthStore.getState().loginWithToken(token);
const loggedIn = await useAuthStore.getState().loginWithToken(token);
await window.daemonAPI.syncToken(token, loggedIn.id);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
@@ -28,6 +50,64 @@ function AppContent() {
});
}, []);
// Sync token and start the daemon whenever the user logs in.
useEffect(() => {
if (!user) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
const userId = user.id;
(async () => {
try {
await window.daemonAPI.syncToken(token, userId);
await window.daemonAPI.autoStart();
} catch (err) {
console.error("Failed to sync daemon on login", err);
}
})();
}, [user]);
// Reconcile the daemon's watched workspaces with what the user is a member
// of. The query already hydrates on login and invalidates on create/delete
// mutations, so this one effect covers both initial sync and incremental
// updates. Opt-outs (unwatched denylist) are respected.
const { data: workspaces } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
const lastSyncedIds = useRef<Set<string>>(new Set());
useEffect(() => {
if (!user || !workspaces || !daemonRunning) return;
(async () => {
const state = await window.daemonAPI.listWatched().catch(() => null);
if (!state) return;
const watchedIds = new Set(state.watched.map((w) => w.id));
const unwatchedIds = new Set(state.unwatched);
const currentIds = new Set(workspaces.map((w) => w.id));
// Add: anything in the API list but not yet watched (and not opted out).
for (const ws of workspaces) {
if (watchedIds.has(ws.id) || unwatchedIds.has(ws.id)) continue;
try {
await window.daemonAPI.watchWorkspace(ws.id, ws.name);
} catch (err) {
console.warn("watch workspace failed", ws.id, err);
}
}
// Remove: anything we previously synced that is no longer in the API
// list (the user left or deleted it).
for (const prevId of lastSyncedIds.current) {
if (!currentIds.has(prevId)) {
try {
await window.daemonAPI.unwatchWorkspace(prevId);
} catch (err) {
console.warn("unwatch workspace failed", prevId, err);
}
}
}
lastSyncedIds.current = currentIds;
})();
}, [user, workspaces, daemonRunning]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
@@ -40,14 +120,32 @@ function AppContent() {
return <DesktopShell />;
}
const remoteProxy = Boolean(import.meta.env.VITE_REMOTE_API);
// Backend the daemon should connect to — same URL the renderer talks to.
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
// On logout, clear any cached PAT and stop the daemon so that a subsequent
// login as a different user never inherits the previous user's credentials.
async function handleDaemonLogout() {
try {
await window.daemonAPI.clearToken();
} catch {
// Best-effort — clearing is followed by stop which also hardens state.
}
try {
await window.daemonAPI.stop();
} catch {
// Daemon may already be stopped.
}
}
export default function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={remoteProxy ? "" : (import.meta.env.VITE_API_URL || "http://localhost:8080")}
wsUrl={remoteProxy ? "ws://localhost:5173/ws" : (import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws")}
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
onLogout={handleDaemonLogout}
>
<AppContent />
</CoreProvider>

View File

@@ -0,0 +1,384 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
Play,
Square,
RotateCw,
Server,
ChevronDown,
X,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@multica/core/workspace";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { toast } from "sonner";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@multica/ui/components/ui/sheet";
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
interface DaemonPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
status: DaemonStatus;
}
const LOG_LEVEL_COLORS: Record<string, string> = {
INFO: "text-info",
WARN: "text-warning",
ERROR: "text-destructive",
DEBUG: "text-muted-foreground",
};
function colorizeLogLine(line: string): { level: string; className: string } {
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
if (line.includes(level)) return { level, className };
}
return { level: "", className: "text-muted-foreground" };
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4 py-1">
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="truncate text-right text-sm">{value}</span>
</div>
);
}
function StatusDot({ state }: { state: DaemonState }) {
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
}
interface LogEntry {
id: number;
line: string;
}
const MAX_LOG_LINES = 500;
let logIdCounter = 0;
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
// Watched workspaces — populated from the daemon when the panel opens and
// refreshed after every toggle so the checkbox state reflects reality.
const { data: allWorkspaces } = useQuery({
...workspaceListOptions(),
enabled: open,
});
const [watchedIds, setWatchedIds] = useState<Set<string>>(new Set());
const [togglingId, setTogglingId] = useState<string | null>(null);
const refreshWatched = useCallback(async () => {
const state = await window.daemonAPI.listWatched().catch(() => null);
if (state) setWatchedIds(new Set(state.watched.map((w) => w.id)));
}, []);
useEffect(() => {
if (open && status.state === "running") void refreshWatched();
}, [open, status.state, refreshWatched]);
const handleToggleWatch = useCallback(
async (id: string, name: string, nextChecked: boolean) => {
setTogglingId(id);
try {
if (nextChecked) {
await window.daemonAPI.watchWorkspace(id, name);
} else {
await window.daemonAPI.unwatchWorkspace(id);
}
await refreshWatched();
} catch (err) {
toast.error(
nextChecked ? "Failed to watch workspace" : "Failed to unwatch workspace",
{ description: err instanceof Error ? err.message : String(err) },
);
} finally {
setTogglingId(null);
}
},
[refreshWatched],
);
useEffect(() => {
if (!open) return;
window.daemonAPI.startLogStream();
const unsub = window.daemonAPI.onLogLine((line) => {
setLogs((prev) => {
const next = [...prev, { id: ++logIdCounter, line }];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
return () => {
unsub();
window.daemonAPI.stopLogStream();
};
}, [open]);
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
const handleLogScroll = useCallback(() => {
const el = logContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setAutoScroll(atBottom);
}, []);
const scrollToBottom = useCallback(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
}
}, []);
const handleStart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.start();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to start daemon", { description: result.error });
}
}, []);
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to stop daemon", { description: result.error });
}
}, []);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
}
}, []);
const isTransitioning = status.state === "starting" || status.state === "stopping";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex flex-col sm:max-w-md"
showCloseButton={false}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
<SheetTitle className="flex items-center gap-2">
<Server className="size-4" />
Local Daemon
</SheetTitle>
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Close"
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
</SheetHeader>
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
<div className="shrink-0 space-y-4">
{/* Status info */}
<div className="rounded-lg border p-3 space-y-0.5">
<InfoRow
label="Status"
value={
<span className="flex items-center gap-1.5">
<StatusDot state={status.state} />
{DAEMON_STATE_LABELS[status.state]}
</span>
}
/>
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
<InfoRow label="Profile" value={status.profile || "default"} />
{status.serverUrl && (
<InfoRow
label="Server"
value={
<span className="font-mono text-xs" title={status.serverUrl}>
{status.serverUrl}
</span>
}
/>
)}
{status.agents && status.agents.length > 0 && (
<InfoRow label="Agents" value={status.agents.join(", ")} />
)}
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
{status.daemonId && (
<InfoRow
label="Daemon ID"
value={<span className="font-mono text-xs">{status.daemonId}</span>}
/>
)}
{typeof status.workspaceCount === "number" && (
<InfoRow label="Workspaces" value={status.workspaceCount} />
)}
{status.pid && (
<InfoRow
label="PID"
value={<span className="font-mono text-xs">{status.pid}</span>}
/>
)}
</div>
{/* Actions */}
{status.state === "installing_cli" ? (
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
Setting up the local runtime this only happens the first time.
</div>
) : status.state === "cli_not_found" ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
<p className="text-sm">
Couldn&apos;t download the local runtime. Check your network
connection and try again.
</p>
<Button
size="sm"
variant="outline"
onClick={async () => {
setActionLoading(true);
try {
await window.daemonAPI.retryInstall();
} finally {
setActionLoading(false);
}
}}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
) : (
<div className="flex gap-2">
{status.state === "stopped" ? (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
<Play className="size-3.5 mr-1.5" />
Start
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleStop}
disabled={actionLoading || isTransitioning}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={actionLoading || isTransitioning}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
</>
)}
</div>
)}
{/* Watched workspaces */}
{status.state === "running" && allWorkspaces && allWorkspaces.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Watched Workspaces</h3>
<div className="rounded-lg border divide-y max-h-48 overflow-y-auto">
{allWorkspaces.map((ws) => {
const checked = watchedIds.has(ws.id);
const disabled = togglingId === ws.id;
return (
<label
key={ws.id}
className={cn(
"flex items-center gap-2.5 px-3 py-2",
disabled
? "opacity-60 cursor-wait"
: "cursor-pointer hover:bg-muted/40",
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(next) =>
handleToggleWatch(ws.id, ws.name, next === true)
}
/>
<span className="truncate text-sm">{ws.name}</span>
</label>
);
})}
</div>
</div>
)}
</div>
{/* Logs — fills remaining vertical space down to the sheet bottom */}
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
<div className="flex items-center justify-between shrink-0">
<h3 className="text-sm font-medium">Logs</h3>
{!autoScroll && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={scrollToBottom}
>
<ChevronDown className="size-3 mr-1" />
Scroll to bottom
</Button>
)}
</div>
<div
ref={logContainerRef}
onScroll={handleLogScroll}
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
>
{logs.length === 0 ? (
<p className="text-muted-foreground/50 text-center py-8">
{status.state === "running"
? "Waiting for logs…"
: "Start the daemon to see logs"}
</p>
) : (
logs.map((entry) => {
const { className } = colorizeLogLine(entry.line);
return (
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
{entry.line}
</div>
);
})
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect, useCallback } from "react";
import {
Play,
Square,
RotateCw,
Server,
Activity,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import type { DaemonStatus } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
export function DaemonRuntimeCard() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
window.daemonAPI.getStatus().then((s) => setStatus(s));
const unsub = window.daemonAPI.onStatusChange((s) => {
setStatus(s);
setActionLoading(false);
});
return unsub;
}, []);
const handleStart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.start();
if (!result.success) {
setActionLoading(false);
toast.error("Failed to start daemon", { description: result.error });
}
}, []);
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
if (!result.success) {
toast.error("Failed to stop daemon", { description: result.error });
}
}, []);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
}
}, []);
const isTransitioning = status.state === "starting" || status.state === "stopping";
const isRunning = status.state === "running";
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
return (
<>
<div
role="button"
tabIndex={0}
onClick={() => setPanelOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setPanelOpen(true);
}
}}
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
<Server className="size-4 text-muted-foreground" />
</div>
<div>
<h3 className="text-sm font-medium">Local Daemon</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
{isRunning && status.uptime && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
</>
)}
{isRunning && status.agents && status.agents.length > 0 && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
</>
)}
</div>
</div>
</div>
<div
className="flex items-center gap-1.5 shrink-0"
onClick={stopPropagation}
>
{isStopped && (
<Button
size="sm"
variant="outline"
onClick={handleStart}
disabled={actionLoading || status.state === "cli_not_found"}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isRunning && (
<>
<Button
size="sm"
variant="ghost"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="outline"
onClick={handleStop}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
{isTransitioning && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</div>
</div>
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
</>
);
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import type { DaemonPrefs } from "../../../shared/daemon-types";
function SettingRow({
label,
description,
children,
}: {
label: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
export function DaemonSettingsTab() {
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
window.daemonAPI.getPrefs().then(setPrefs);
window.daemonAPI.isCliInstalled().then(setCliInstalled);
}, []);
const updatePref = useCallback(
async (key: keyof DaemonPrefs, value: boolean) => {
setSaving(true);
const updated = await window.daemonAPI.setPrefs({ [key]: value });
setPrefs(updated);
setSaving(false);
},
[],
);
return (
<div>
<h2 className="text-lg font-semibold">Daemon</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure how the local agent daemon behaves with the desktop app.
</p>
<div className="mt-6 divide-y">
<SettingRow
label="Auto-start on launch"
description="Automatically start the daemon when the app opens and you are logged in."
>
<Switch
checked={prefs.autoStart}
onCheckedChange={(checked) => updatePref("autoStart", checked)}
disabled={saving}
/>
</SettingRow>
<SettingRow
label="Auto-stop on quit"
description="Stop the daemon when the desktop app is closed. Disable this to keep the daemon running in the background."
>
<Switch
checked={prefs.autoStop}
onCheckedChange={(checked) => updatePref("autoStop", checked)}
disabled={saving}
/>
</SettingRow>
<div className="py-4">
<p className="text-sm font-medium">CLI Status</p>
<p className="text-sm text-muted-foreground mt-1">
{cliInstalled === null
? "Checking…"
: cliInstalled
? "multica CLI is installed and available in PATH."
: "multica CLI not found. Install it to enable daemon management."}
</p>
{cliInstalled === false && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() =>
window.desktopAPI.openExternal(
"https://github.com/multica-ai/multica#cli-installation",
)
}
>
Installation Guide
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,13 @@
import { useEffect } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
import {
SidebarProvider,
useSidebar,
} from "@multica/ui/components/ui/sidebar";
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
@@ -28,6 +32,7 @@ function SidebarTopBar() {
<button
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
@@ -35,6 +40,7 @@ function SidebarTopBar() {
<button
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
@@ -44,6 +50,23 @@ function SidebarTopBar() {
);
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is collapsed, we pad the left side so tabs don't land under the macOS
// traffic lights (which live at roughly x=16..68 and always hit-test above HTML).
function MainTopBar() {
const { state } = useSidebar();
const sidebarCollapsed = state === "collapsed";
return (
<header
className={cn("h-12 shrink-0", sidebarCollapsed && "pl-20")}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<TabBar />
</header>
);
}
function useInternalLinkHandler() {
useEffect(() => {
const handler = (e: Event) => {
@@ -78,13 +101,7 @@ export function DesktopShell() {
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
{/* Tab bar + drag region */}
<header
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<TabBar />
</header>
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />

View File

@@ -13,9 +13,14 @@ import { ProjectsPage } from "@multica/views/projects/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { OnboardingWizard } from "@multica/views/onboarding";
import { useNavigation } from "@multica/views/navigation";
import { Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
/**
* Sets document.title from the deepest matched route's handle.title.
@@ -47,6 +52,11 @@ function PageShell() {
);
}
function OnboardingRoute() {
const nav = useNavigation();
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
}
/** Route definitions shared by all tabs (no layout wrapper). */
export const appRoutes: RouteObject[] = [
{
@@ -76,15 +86,31 @@ export const appRoutes: RouteObject[] = [
},
{
path: "runtimes",
element: <RuntimesPage />,
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "onboarding",
element: <OnboardingRoute />,
handle: { title: "Get Started" },
},
{
path: "settings",
element: <SettingsPage />,
element: (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
]}
/>
),
handle: { title: "Settings" },
},
],

View File

@@ -0,0 +1,53 @@
export type DaemonState =
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found";
export interface DaemonStatus {
state: DaemonState;
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
/** CLI profile this daemon belongs to. Empty string means the default profile. */
profile?: string;
/** Backend URL the daemon connects to. */
serverUrl?: string;
}
export interface DaemonPrefs {
autoStart: boolean;
autoStop: boolean;
}
export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
running: "bg-emerald-500",
stopped: "bg-muted-foreground/40",
starting: "bg-amber-500 animate-pulse",
stopping: "bg-amber-500 animate-pulse",
installing_cli: "bg-sky-500 animate-pulse",
cli_not_found: "bg-red-500",
};
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
running: "Running",
stopped: "Stopped",
starting: "Starting…",
stopping: "Stopping…",
installing_cli: "Setting up…",
cli_not_found: "Setup Failed",
};
export function formatUptime(uptime?: string): string {
if (!uptime) return "";
const match = uptime.match(/(?:(\d+)h)?(\d+)m/);
if (!match) return uptime;
const h = match[1] ? `${match[1]}h ` : "";
const m = match[2] ? `${match[2]}m` : "";
return `${h}${m}`.trim() || uptime;
}

View File

@@ -36,14 +36,19 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
## Reusable Skills
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
Multica supports two layers of skills:
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
- Deployments
- Migrations
- Code reviews
- Common patterns
Skills are shared across the workspace, so any agent (or human) can leverage them.
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
## Multi-Workspace Support

View File

@@ -45,6 +45,10 @@ const nextConfig: NextConfig = {
source: "/auth/:path*",
destination: `${remoteApiUrl}/auth/:path*`,
},
{
source: "/uploads/:path*",
destination: `${remoteApiUrl}/uploads/:path*`,
},
];
},
};

View File

@@ -6,7 +6,6 @@
"scripts": {
"dev:web": "turbo dev --filter=@multica/web",
"dev:desktop": "turbo dev --filter=@multica/desktop",
"dev:desktop:remote": "pnpm --filter @multica/desktop dev:remote",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",

View File

@@ -41,6 +41,8 @@ import type {
Attachment,
ChatSession,
ChatMessage,
ChatPendingTask,
PendingChatTasksResponse,
SendChatMessageResponse,
Project,
CreateProjectRequest,
@@ -703,6 +705,18 @@ export class ApiClient {
});
}
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
}
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
return this.fetch(`/api/chat/pending-tasks`);
}
async markChatSessionRead(sessionId: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
}
async cancelTaskById(taskId: string): Promise<void> {
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
}

View File

@@ -1,4 +1,4 @@
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H } from "./store";
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
import type { createChatStore as CreateChatStoreFn } from "./store";

View File

@@ -2,15 +2,67 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys } from "./queries";
import { createLogger } from "../logger";
import type { ChatSession } from "../types";
const logger = createLogger("chat.mut");
export function useCreateChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { agent_id: string; title?: string }) =>
api.createChatSession(data),
mutationFn: (data: { agent_id: string; title?: string }) => {
logger.info("createChatSession.start", { agent_id: data.agent_id, titleLength: data.title?.length ?? 0 });
return api.createChatSession(data);
},
onSuccess: (session) => {
logger.info("createChatSession.success", { sessionId: session.id, agentId: session.agent_id });
},
onError: (err) => {
logger.error("createChatSession.error", err);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}
/**
* Clears the session's unread state server-side. Optimistically flips
* has_unread to false in the cached lists so the FAB badge drops
* immediately. The server broadcasts chat:session_read so other devices
* also sync.
*/
export function useMarkChatSessionRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (sessionId: string) => {
logger.info("markChatSessionRead.start", { sessionId });
return api.markChatSessionRead(sessionId);
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
const clear = (old?: ChatSession[]) =>
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
return { prevSessions, prevAll };
},
onError: (err, sessionId, ctx) => {
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
@@ -23,7 +75,10 @@ export function useArchiveChatSession() {
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
mutationFn: (sessionId: string) => {
logger.info("archiveChatSession.start", { sessionId });
return api.archiveChatSession(sessionId);
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
@@ -41,13 +96,16 @@ export function useArchiveChatSession() {
),
);
logger.debug("archiveChatSession.optimistic", { sessionId });
return { prevSessions, prevAll };
},
onError: (_err, _id, ctx) => {
onError: (err, sessionId, ctx) => {
logger.error("archiveChatSession.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: () => {
onSettled: (_data, _err, sessionId) => {
logger.debug("archiveChatSession.settled", { sessionId });
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},

View File

@@ -14,6 +14,11 @@ export const chatKeys = {
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
/** Per-task execution messages — shared with issue agent cards. */
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
};
export function chatSessionsOptions(wsId: string) {
@@ -49,3 +54,44 @@ export function chatMessagesOptions(sessionId: string) {
staleTime: Infinity,
});
}
/**
* Pending task for a chat session — the "is something still running?" signal.
* Refetched via WS invalidation in useRealtimeSync when chat:message / chat:done
* / task:completed / task:failed arrive.
*/
export function pendingChatTaskOptions(sessionId: string) {
return queryOptions({
queryKey: chatKeys.pendingTask(sessionId),
queryFn: () => api.getPendingChatTask(sessionId),
enabled: !!sessionId,
staleTime: Infinity,
});
}
/**
* Timeline for a single task — rendered by both the live chat view (while a
* task is running) and AssistantMessage (for completed tasks). WS
* `task:message` events seed this cache in real time via useRealtimeSync.
*/
export function taskMessagesOptions(taskId: string) {
return queryOptions({
queryKey: chatKeys.taskMessages(taskId),
queryFn: () => api.listTaskMessages(taskId),
enabled: !!taskId,
staleTime: Infinity,
});
}
/**
* Aggregate of in-flight chat tasks for the current user in this workspace.
* Drives the FAB "running" indicator while the chat window is minimised —
* no per-session query is active then, so we need this roll-up.
*/
export function pendingChatTasksOptions(wsId: string) {
return queryOptions({
queryKey: chatKeys.pendingTasks(wsId),
queryFn: () => api.listPendingChatTasks(),
staleTime: Infinity,
});
}

View File

@@ -1,19 +1,54 @@
import { create } from "zustand";
import type { StorageAdapter } from "../types";
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { createLogger } from "../logger";
const logger = createLogger("chat.store");
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
const DRAFT_KEY = "multica:chat:draft";
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
const DRAFTS_KEY = "multica:chat:drafts";
/** Placeholder sessionId for a chat that hasn't been created yet. */
export const DRAFT_NEW_SESSION = "__new__";
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
const raw = storage.getItem(key);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string, string>) {
// Prune empty entries so the blob doesn't grow unbounded.
const pruned: Record<string, string> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v) pruned[k] = v;
}
if (Object.keys(pruned).length === 0) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(pruned));
}
}
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_H = 600;
/**
* Kept as a public type because existing consumers (chat-message-list,
* views/chat types) import it. Items themselves no longer live in the
* store — they flow through the React Query cache keyed by task id.
*/
export interface ChatTimelineItem {
seq: number;
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
@@ -26,11 +61,10 @@ export interface ChatTimelineItem {
export interface ChatState {
isOpen: boolean;
activeSessionId: string | null;
pendingTaskId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
timelineItems: ChatTimelineItem[];
inputDraft: string;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
@@ -38,13 +72,11 @@ export interface ChatState {
setOpen: (open: boolean) => void;
toggle: () => void;
setActiveSession: (id: string | null) => void;
setPendingTask: (taskId: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
addTimelineItem: (item: ChatTimelineItem) => void;
clearTimeline: () => void;
setInputDraft: (draft: string) => void;
clearInputDraft: () => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
@@ -62,20 +94,26 @@ export function createChatStore(options: ChatStoreOptions) {
return wsId ? `${base}:${wsId}` : base;
};
const store = create<ChatState>((set) => ({
const store = create<ChatState>((set, get) => ({
isOpen: false,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
pendingTaskId: null,
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
timelineItems: [],
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
setOpen: (open) => set({ isOpen: open }),
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
setOpen: (open) => {
logger.debug("setOpen", { from: get().isOpen, to: open });
set({ isOpen: open });
},
toggle: () => {
const next = !get().isOpen;
logger.debug("toggle", { to: next });
set({ isOpen: next });
},
setActiveSession: (id) => {
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
if (id) {
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
} else {
@@ -83,35 +121,36 @@ export function createChatStore(options: ChatStoreOptions) {
}
set({ activeSessionId: id });
},
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
setSelectedAgentId: (id) => {
logger.info("setSelectedAgentId", { from: get().selectedAgentId, to: id });
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => set({ showHistory: show }),
setInputDraft: (draft) => {
if (draft) {
storage.setItem(wsKey(DRAFT_KEY), draft);
} else {
storage.removeItem(wsKey(DRAFT_KEY));
setShowHistory: (show) => {
logger.debug("setShowHistory", { to: show });
set({ showHistory: show });
},
setInputDraft: (sessionId, draft) => {
// Debug level — onUpdate fires on every keystroke.
logger.debug("setInputDraft", { sessionId, length: draft.length });
const next = { ...get().inputDrafts, [sessionId]: draft };
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
clearInputDraft: (sessionId) => {
const current = get().inputDrafts;
if (!(sessionId in current)) {
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
return;
}
set({ inputDraft: draft });
logger.info("clearInputDraft", { sessionId });
const next = { ...current };
delete next[sessionId];
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
clearInputDraft: () => {
storage.removeItem(wsKey(DRAFT_KEY));
set({ inputDraft: "" });
},
addTimelineItem: (item) =>
set((s) => {
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
return {
timelineItems: [...s.timelineItems, item].sort(
(a, b) => a.seq - b.seq,
),
};
}),
clearTimeline: () => set({ timelineItems: [] }),
setChatSize: (w, h) => {
logger.debug("setChatSize", { w, h });
storage.setItem(CHAT_WIDTH_KEY, String(w));
storage.setItem(CHAT_HEIGHT_KEY, String(h));
// Dragging = user chose a manual size → exit expanded mode
@@ -119,6 +158,7 @@ export function createChatStore(options: ChatStoreOptions) {
set({ chatWidth: w, chatHeight: h, isExpanded: false });
},
setExpanded: (expanded) => {
logger.info("setExpanded", { to: expanded });
if (expanded) {
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
} else {
@@ -129,11 +169,20 @@ export function createChatStore(options: ChatStoreOptions) {
}));
registerForWorkspaceRehydration(() => {
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
logger.info("workspace rehydration", {
prevSession: store.getState().activeSessionId,
nextSession,
prevAgent: store.getState().selectedAgentId,
nextAgent,
draftCount: Object.keys(nextDrafts).length,
});
store.setState({
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
timelineItems: [],
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
});
});

View File

@@ -2,7 +2,6 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus } from "../../types";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
@@ -13,25 +12,22 @@ const MAX_RECENT_ISSUES = 20;
export interface RecentIssueEntry {
id: string;
identifier: string;
title: string;
status: IssueStatus;
visitedAt: number;
}
interface RecentIssuesState {
items: RecentIssueEntry[];
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
recordVisit: (id: string) => void;
}
export const useRecentIssuesStore = create<RecentIssuesState>()(
persist(
(set) => ({
items: [],
recordVisit: (entry) =>
recordVisit: (id) =>
set((state) => {
const filtered = state.items.filter((i) => i.id !== entry.id);
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
const filtered = state.items.filter((i) => i.id !== id);
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
return {
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
};

View File

@@ -17,6 +17,8 @@ describe("clearWorkspaceStorage", () => {
expect(adapter.removeItem).toHaveBeenCalledWith("multica_my_issues_view:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:selectedAgentId:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
expect(adapter.removeItem).toHaveBeenCalledTimes(6);
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:drafts:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:expanded:ws_123");
expect(adapter.removeItem).toHaveBeenCalledTimes(8);
});
});

View File

@@ -14,6 +14,8 @@ const WORKSPACE_SCOPED_KEYS = [
"multica_my_issues_view",
"multica:chat:selectedAgentId",
"multica:chat:activeSessionId",
"multica:chat:drafts",
"multica:chat:expanded",
];
/** Remove all workspace-scoped storage entries for the given workspace. */

View File

@@ -21,6 +21,7 @@ import {
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import { chatKeys } from "../chat/queries";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
@@ -39,8 +40,14 @@ import type {
IssueReactionRemovedPayload,
SubscriberAddedPayload,
SubscriberRemovedPayload,
TaskMessagePayload,
TaskCompletedPayload,
TaskFailedPayload,
ChatDonePayload,
} from "../types";
const chatWsLogger = createLogger("chat.ws");
const logger = createLogger("realtime-sync");
export interface RealtimeSyncStores {
@@ -133,6 +140,9 @@ export function useRealtimeSync(
"issue_reaction:added", "issue_reaction:removed",
"subscriber:added", "subscriber:removed",
"daemon:heartbeat",
// Chat / task events are handled explicitly below; do not double-invalidate.
"chat:message", "chat:done", "chat:session_read",
"task:message", "task:completed", "task:failed",
]);
const unsubAny = ws.onAny((msg) => {
@@ -283,6 +293,103 @@ export function useRealtimeSync(
}
});
// --- Chat / task events (global, survives ChatWindow unmount) ---
//
// Single source of truth: the Query cache. No Zustand writes here — the
// earlier mirror caused a race where the cache and store disagreed
// during the invalidate → refetch window and the UI rendered duplicates.
//
// task:message is written directly into the task-messages cache so the
// live timeline updates in place. chat:message / chat:done /
// task:completed / task:failed invalidate messages + pending-task so the
// DB remains authoritative.
const unsubTaskMessage = ws.on("task:message", (p) => {
const payload = p as TaskMessagePayload;
qc.setQueryData<TaskMessagePayload[]>(
["task-messages", payload.task_id],
(old = []) => {
if (old.some((m) => m.seq === payload.seq)) return old;
return [...old, payload].sort((a, b) => a.seq - b.seq);
},
);
chatWsLogger.debug("task:message (global)", {
task_id: payload.task_id,
seq: payload.seq,
type: payload.type,
});
});
// Helpers reused by chat lifecycle handlers.
const invalidatePendingAggregate = () => {
const id = workspaceStore.getState().workspace?.id;
if (id) qc.invalidateQueries({ queryKey: chatKeys.pendingTasks(id) });
};
const invalidateSessionLists = () => {
const id = workspaceStore.getState().workspace?.id;
if (id) {
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
}
};
const unsubChatMessage = ws.on("chat:message", (p) => {
const payload = p as { chat_session_id: string };
chatWsLogger.info("chat:message (global)", { chat_session_id: payload.chat_session_id });
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
});
const unsubChatDone = ws.on("chat:done", (p) => {
const payload = p as ChatDonePayload;
chatWsLogger.info("chat:done (global)", {
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
// Assistant message was just written and task flipped out of 'running'.
// Clear pending-task cache immediately so the live-timeline-vs-assistant
// race window collapses to zero — the subsequent refetch will confirm.
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
// Assistant message just landed → has_unread may have flipped to true.
invalidateSessionLists();
});
const unsubTaskCompleted = ws.on("task:completed", (p) => {
const payload = p as TaskCompletedPayload;
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
chatWsLogger.info("task:completed (global, chat)", {
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
});
const unsubTaskFailed = ws.on("task:failed", (p) => {
const payload = p as TaskFailedPayload;
if (!payload.chat_session_id) return;
chatWsLogger.warn("task:failed (global, chat)", {
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
// No new message; just flip the pending signal.
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
});
const unsubChatSessionRead = ws.on("chat:session_read", (p) => {
const payload = p as { chat_session_id: string };
chatWsLogger.info("chat:session_read (global)", payload);
invalidateSessionLists();
});
return () => {
unsubAny();
unsubIssueUpdated();
@@ -302,6 +409,12 @@ export function useRealtimeSync(
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();
unsubTaskMessage();
unsubChatMessage();
unsubChatDone();
unsubTaskCompleted();
unsubTaskFailed();
unsubChatSessionRead();
timers.forEach(clearTimeout);
timers.clear();
};

View File

@@ -5,10 +5,22 @@ export interface ChatSession {
creator_id: string;
title: string;
status: "active" | "archived";
/** True when the session has any unread assistant replies. List-only. */
has_unread: boolean;
created_at: string;
updated_at: string;
}
export interface PendingChatTaskItem {
task_id: string;
status: string;
chat_session_id: string;
}
export interface PendingChatTasksResponse {
tasks: PendingChatTaskItem[];
}
export interface ChatMessage {
id: string;
chat_session_id: string;
@@ -22,3 +34,12 @@ export interface SendChatMessageResponse {
message_id: string;
task_id: string;
}
/**
* Response from GET /api/chat/sessions/{id}/pending-task.
* Both fields are absent when the session has no in-flight task.
*/
export interface ChatPendingTask {
task_id?: string;
status?: string;
}

View File

@@ -48,6 +48,7 @@ export type WSEventType =
| "issue_reaction:removed"
| "chat:message"
| "chat:done"
| "chat:session_read"
| "project:created"
| "project:updated"
| "project:deleted"
@@ -170,6 +171,7 @@ export interface ActivityCreatedPayload {
export interface TaskMessagePayload {
task_id: string;
issue_id: string;
chat_session_id?: string;
seq: number;
type: "text" | "thinking" | "tool_use" | "tool_result" | "error";
tool?: string;
@@ -182,6 +184,7 @@ export interface TaskCompletedPayload {
task_id: string;
agent_id: string;
issue_id: string;
chat_session_id?: string;
status: string;
}
@@ -189,6 +192,7 @@ export interface TaskFailedPayload {
task_id: string;
agent_id: string;
issue_id: string;
chat_session_id?: string;
status: string;
}
@@ -196,6 +200,7 @@ export interface TaskCancelledPayload {
task_id: string;
agent_id: string;
issue_id: string;
chat_session_id?: string;
status: string;
}
@@ -239,6 +244,10 @@ export interface ChatDonePayload {
content?: string;
}
export interface ChatSessionReadPayload {
chat_session_id: string;
}
export interface ProjectCreatedPayload {
project: Project;
}

View File

@@ -30,7 +30,7 @@ export type { IssueSubscriber } from "./subscriber";
export type * from "./events";
export type * from "./api";
export type { Attachment } from "./attachment";
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
export type { StorageAdapter } from "./storage";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";

View File

@@ -115,7 +115,11 @@ function createComponents(
const id = mentionMatch[2]
if (renderMention) {
return <>{renderMention({ type, id })}</>
// Let the custom renderer opt out for types it doesn't handle
// by returning null/undefined — we then fall through to the
// default styled span so nothing ever disappears silently.
const rendered = renderMention({ type, id })
if (rendered) return <>{rendered}</>
}
// Fallback: render as a simple styled span

View File

@@ -25,6 +25,24 @@
animation: entrance-spin 0.6s ease-out forwards;
}
/* Chat FAB: gentle color + border tint while a chat task is running.
* Keeps the ring at the same thickness — only hue shifts towards brand
* at half-cycle, no outer glow. */
@keyframes chat-impulse {
0%, 100% {
color: var(--muted-foreground);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
}
50% {
color: var(--brand);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--brand) 40%, transparent);
}
}
.animate-chat-impulse {
animation: chat-impulse 1.6s ease-in-out infinite;
}
/* Sidebar: open triggers (dropdown/popover) get active background */
[data-sidebar="menu-button"][data-popup-open] {
background-color: var(--sidebar-accent);

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Plus, FileText, Trash2 } from "lucide-react";
import { Plus, FileText, Trash2, Info } from "lucide-react";
import type { Agent } from "@multica/core/types";
import {
Dialog,
@@ -65,7 +65,7 @@ export function SkillsTab({
<div>
<h3 className="text-sm font-semibold">Skills</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Reusable skills assigned to this agent. Manage skills on the Skills page.
Workspace skills assigned to this agent.
</p>
</div>
<Button
@@ -79,12 +79,19 @@ export function SkillsTab({
</Button>
</div>
<div className="flex items-start gap-2 rounded-md border border-info/20 bg-info/5 px-3 py-2.5">
<Info className="h-3.5 w-3.5 shrink-0 text-info mt-0.5" />
<p className="text-xs text-muted-foreground">
Local runtime skills (from your CLI&apos;s skills directory) are always available automatically no need to add them here.
</p>
</div>
{agent.skills.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
<FileText className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No skills assigned</p>
<p className="mt-1 text-xs text-muted-foreground">
Add skills from the workspace to this agent.
Add workspace skills to share team knowledge with this agent. Local skills are already used automatically.
</p>
{availableSkills.length > 0 && (
<Button

View File

@@ -1,28 +1,63 @@
"use client";
import { MessageCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { useChatStore } from "@multica/core/chat";
import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { createLogger } from "@multica/core/logger";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
const logger = createLogger("chat.ui");
export function ChatFab() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const toggle = useChatStore((s) => s.toggle);
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
if (isOpen) return null;
const unreadSessionCount = sessions.filter((s) => s.has_unread).length;
const isRunning = (pending?.tasks ?? []).length > 0;
const handleClick = () => {
logger.info("fab.click (open chat)", { unreadSessionCount, isRunning });
toggle();
};
// Tooltip text communicates the state that isn't carried by the icon/badge.
const tooltip = isRunning
? "Multica is working..."
: unreadSessionCount > 0
? `${unreadSessionCount} unread ${unreadSessionCount === 1 ? "chat" : "chats"}`
: "Ask Multica";
return (
<Tooltip>
<TooltipTrigger
onClick={toggle}
className="absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95"
onClick={handleClick}
className={cn(
"absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95",
// Impulse the button itself while a chat task is running — no
// outer ring to keep things calm.
isRunning && "animate-chat-impulse",
)}
>
<MessageCircle className="size-5" />
{unreadSessionCount > 0 && (
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex min-w-4 h-4 items-center justify-center rounded-full bg-brand px-1 text-xs font-semibold leading-none text-background">
{unreadSessionCount > 9 ? "9+" : unreadSessionCount}
</span>
)}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>Ask Multica</TooltipContent>
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,49 +1,106 @@
"use client";
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useChatStore } from "@multica/core/chat";
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
const logger = createLogger("chat.ui");
interface ChatInputProps {
onSend: (content: string) => void;
onStop?: () => void;
isRunning?: boolean;
disabled?: boolean;
/** Name of the currently selected agent, used in the placeholder. */
agentName?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
leftAdornment?: ReactNode;
}
export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProps) {
export function ChatInput({
onSend,
onStop,
isRunning,
disabled,
agentName,
leftAdornment,
}: ChatInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const inputDraft = useChatStore((s) => s.inputDraft);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
// Scope the new-chat draft by agent:
// 1. Switching agents while composing a brand-new chat gives each
// agent its own draft (no cross-agent leakage).
// 2. Tiptap's Placeholder extension is only applied at mount; this
// key changes on agent switch so the editor remounts and the
// `Tell {agent} what to do…` placeholder refreshes.
const draftKey =
activeSessionId ?? `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
// Select a primitive — empty-string fallback keeps referential stability.
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
const setInputDraft = useChatStore((s) => s.setInputDraft);
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
const handleSend = () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || isRunning || disabled) return;
if (!content || isRunning || disabled) {
logger.debug("input.send skipped", {
emptyContent: !content,
isRunning,
disabled,
});
return;
}
// Capture draft key BEFORE onSend — creating a new session mutates
// activeSessionId synchronously, so reading it after onSend would point
// at the new session and leave the old draft orphaned.
const keyAtSend = draftKey;
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
onSend(content);
editorRef.current?.clearContent();
clearInputDraft();
clearInputDraft(keyAtSend);
setIsEmpty(true);
};
const placeholder = disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
return (
<div className="p-2 pt-0">
<div className="relative flex min-h-16 max-h-40 flex-col rounded-lg bg-card pb-8 border-1 border-border transition-colors focus-within:border-brand">
<div className="px-5 pb-3 pt-0">
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
// Remount the editor when the active session changes so its
// uncontrolled defaultValue picks up the new session's draft.
key={draftKey}
ref={editorRef}
defaultValue={inputDraft}
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
placeholder={placeholder}
onUpdate={(md) => {
setIsEmpty(!md.trim());
setInputDraft(md);
setInputDraft(draftKey, md);
}}
onSubmit={handleSend}
debounceMs={100}
// Chat is short-form — the floating formatting toolbar is
// more distraction than feature here.
showBubbleMenu={false}
// Enter sends; Shift-Enter inserts a hard break.
submitOnEnter
/>
</div>
{leftAdornment && (
<div className="absolute bottom-1.5 left-2 flex items-center">
{leftAdornment}
</div>
)}
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<SubmitButton
onClick={handleSend}

View File

@@ -11,7 +11,7 @@ import {
import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
import { api } from "@multica/core/api";
import { taskMessagesOptions } from "@multica/core/chat/queries";
import { Markdown } from "@multica/views/common/markdown";
import type { ChatMessage, TaskMessagePayload } from "@multica/core/types";
import type { ChatTimelineItem } from "@multica/core/chat";
@@ -20,51 +20,113 @@ import type { ChatTimelineItem } from "@multica/core/chat";
interface ChatMessageListProps {
messages: ChatMessage[];
timelineItems: ChatTimelineItem[];
/** When set, streams the live timeline for this task from task-messages cache. */
pendingTaskId: string | null;
isWaiting: boolean;
}
export function ChatMessageList({
messages,
timelineItems,
pendingTaskId,
isWaiting,
}: ChatMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const fadeStyle = useScrollFade(scrollRef);
useAutoScroll(scrollRef);
const hasTimeline = timelineItems.length > 0;
// Once the assistant message for this pending task has landed in the
// messages list, AssistantMessage owns its rendering — suppress the live
// timeline to avoid rendering the same content in two places during the
// invalidate → refetch window.
const pendingAlreadyPersisted = !!pendingTaskId && messages.some(
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
);
// Live timeline for the in-flight task. useRealtimeSync keeps this cache
// current via setQueryData on task:message events.
const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted;
const { data: liveTaskMessages } = useQuery({
...taskMessagesOptions(pendingTaskId ?? ""),
enabled: showLiveTimeline,
});
const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem);
const hasLive = showLiveTimeline && liveTimeline.length > 0;
return (
<div
ref={scrollRef}
style={fadeStyle}
className="flex-1 overflow-y-auto px-4 py-3 space-y-4"
>
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{/* Live streaming timeline */}
{hasTimeline && (
<div className="w-full space-y-1.5">
<TimelineView items={timelineItems} />
</div>
)}
{isWaiting && !hasTimeline && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<div ref={scrollRef} style={fadeStyle} className="flex-1 overflow-y-auto">
{/* Inner container matches issue / project detail width convention
* (max-w-4xl + mx-auto) so switching between chat and content
* views doesn't jolt the reading width. px-5 is a touch tighter
* than issue-detail's px-8 because the chat window can be narrow. */}
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-4">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{hasLive && (
<div className="w-full space-y-1.5">
<TimelineView items={liveTimeline} />
</div>
)}
{isWaiting && !hasLive && !pendingAlreadyPersisted && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
</div>
</div>
);
}
/**
* Placeholder shown while `chat_message` for a session is being fetched
* (initial refresh, or switching to an un-cached session). Shape roughly
* mirrors an assistant → user → assistant exchange so the window doesn't
* shift under the user when real messages arrive.
*/
export function ChatMessageSkeleton() {
return (
<div className="flex-1 overflow-hidden">
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-5">
<div className="space-y-2">
<div className="h-3.5 w-3/4 rounded bg-muted animate-pulse" />
<div className="h-3.5 w-1/2 rounded bg-muted animate-pulse" />
</div>
<div className="flex justify-end">
<div className="h-8 w-48 rounded-2xl bg-muted animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-3.5 w-2/3 rounded bg-muted animate-pulse" />
<div className="h-3.5 w-5/6 rounded bg-muted animate-pulse" />
<div className="h-3.5 w-1/3 rounded bg-muted animate-pulse" />
</div>
</div>
</div>
);
}
function toTimelineItem(m: TaskMessagePayload): ChatTimelineItem {
return {
seq: m.seq,
type: m.type,
tool: m.tool,
content: m.content,
input: m.input,
output: m.output,
};
}
// ─── Message bubbles ─────────────────────────────────────────────────────
function MessageBubble({ message }: { message: ChatMessage }) {
if (message.role === "user") {
return (
<div className="flex justify-end">
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] whitespace-pre-wrap break-words">
{message.content}
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] break-words">
{/* User messages are authored as markdown in ContentEditor, so
* render them through the same pipeline as assistant replies.
* Neutralise prose's leading/trailing margin so single-line
* bubbles stay as compact as the plain-text version used to. */}
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<Markdown>{message.content}</Markdown>
</div>
</div>
</div>
);
@@ -80,24 +142,15 @@ function AssistantMessage({
}) {
const taskId = message.task_id;
// Always fetch task messages for assistant messages with a task_id
// Use the shared taskMessagesOptions so this cache entry is the same one
// seeded by useRealtimeSync during task execution — zero refetch when the
// task finishes, since WS already populated it.
const { data: taskMessages } = useQuery({
queryKey: ["task-messages", taskId],
queryFn: () => api.listTaskMessages(taskId!),
...taskMessagesOptions(taskId ?? ""),
enabled: !!taskId,
staleTime: Infinity,
});
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(
(m: TaskMessagePayload) => ({
seq: m.seq,
type: m.type,
tool: m.tool,
content: m.content,
input: m.input,
output: m.output,
}),
);
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(toTimelineItem);
return (
<div className="w-full space-y-1.5">

View File

@@ -10,14 +10,15 @@ import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { allChatSessionsOptions } from "@multica/core/chat/queries";
import { useChatStore } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
import type { ChatSession, Agent } from "@multica/core/types";
const logger = createLogger("chat.ui");
export function ChatSessionHistory() {
const wsId = useWorkspaceId();
const setShowHistory = useChatStore((s) => s.setShowHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const clearTimeline = useChatStore((s) => s.clearTimeline);
const setPendingTask = useChatStore((s) => s.setPendingTask);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
@@ -26,9 +27,15 @@ export function ChatSessionHistory() {
const agentMap = new Map(agents.map((a) => [a.id, a]));
const handleSelectSession = (session: ChatSession) => {
logger.info("selectSession", {
from: activeSessionId,
to: session.id,
agentId: session.agent_id,
status: session.status,
});
// Changing activeSessionId flips the query keys for messages +
// pending-task; no manual clear needed.
setActiveSession(session.id);
clearTimeline();
setPendingTask(null);
setShowHistory(false);
};

View File

@@ -1,8 +1,8 @@
"use client";
import React, { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus, History } from "lucide-react";
import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
@@ -24,43 +24,53 @@ import {
chatSessionsOptions,
allChatSessionsOptions,
chatMessagesOptions,
pendingChatTaskOptions,
chatKeys,
} from "@multica/core/chat/queries";
import { useCreateChatSession } from "@multica/core/chat/mutations";
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { ChatMessageList } from "./chat-message-list";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import { ChatSessionHistory } from "./chat-session-history";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { useWS } from "@multica/core/realtime";
import type { TaskMessagePayload, ChatDonePayload, Agent, ChatMessage } from "@multica/core/types";
import { createLogger } from "@multica/core/logger";
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
export function ChatWindow() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const pendingTaskId = useChatStore((s) => s.pendingTaskId);
const timelineItems = useChatStore((s) => s.timelineItems);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setOpen = useChatStore((s) => s.setOpen);
const showHistory = useChatStore((s) => s.showHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setPendingTask = useChatStore((s) => s.setPendingTask);
const addTimelineItem = useChatStore((s) => s.addTimelineItem);
const clearTimeline = useChatStore((s) => s.clearTimeline);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
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 } = useQuery(
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;
// Check if current session is archived
const currentSession = activeSessionId
@@ -70,6 +80,7 @@ export function ChatWindow() {
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
@@ -83,87 +94,82 @@ export function ChatWindow() {
availableAgents[0] ??
null;
// 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
}, []);
// Auto-restore most recent active session from server (only once on mount)
const didRestoreRef = useRef(false);
useEffect(() => {
if (didRestoreRef.current) return;
didRestoreRef.current = true;
if (activeSessionId || sessions.length === 0) return;
if (activeSessionId || sessions.length === 0) {
uiLogger.debug("restore session skipped", {
reason: activeSessionId ? "already has session" : "no sessions",
activeSessionId,
sessionCount: sessions.length,
});
return;
}
const latest = sessions.find((s) => s.status === "active");
if (latest) {
uiLogger.info("restore session on mount", { sessionId: latest.id });
setActiveSession(latest.id);
} else {
uiLogger.debug("restore session: no active session found");
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
}, [sessions]);
// Use ref for pendingTaskId so WS handlers always see the latest value
// without needing to re-subscribe on every change.
const pendingTaskRef = useRef<string | null>(pendingTaskId);
pendingTaskRef.current = pendingTaskId;
const { subscribe } = useWS();
// 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(() => {
// Returns true if the event was for our pending task and was handled.
// Caller still decides whether to invalidate cache (chat:done / completed do; failed doesn't).
const matchesPending = (taskId: string) =>
!!pendingTaskRef.current && taskId === pendingTaskRef.current;
const finalizePending = (invalidateCache: boolean) => {
if (invalidateCache) {
const sid = useChatStore.getState().activeSessionId;
if (sid) {
qc.invalidateQueries({ queryKey: chatKeys.messages(sid) });
}
}
clearTimeline();
setPendingTask(null);
};
const unsubMessage = subscribe("task:message", (payload) => {
const p = payload as TaskMessagePayload;
if (!matchesPending(p.task_id)) return;
addTimelineItem({
seq: p.seq,
type: p.type,
tool: p.tool,
content: p.content,
input: p.input,
output: p.output,
});
});
const unsubDone = subscribe("chat:done", (payload) => {
const p = payload as ChatDonePayload;
if (!matchesPending(p.task_id)) return;
finalizePending(true);
});
const unsubCompleted = subscribe("task:completed", (payload) => {
const p = payload as { task_id: string };
if (!matchesPending(p.task_id)) return;
finalizePending(true);
});
const unsubFailed = subscribe("task:failed", (payload) => {
const p = payload as { task_id: string };
if (!matchesPending(p.task_id)) return;
finalizePending(false);
});
return () => {
unsubMessage();
unsubDone();
unsubCompleted();
unsubFailed();
};
}, [subscribe, addTimelineItem, clearTimeline, setPendingTask, qc]);
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]);
const handleSend = useCallback(
async (content: string) => {
if (!activeAgent) return;
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return;
}
let sessionId = activeSessionId;
const isNewSession = !sessionId;
apiLogger.info("sendChatMessage.start", {
sessionId,
isNewSession,
agentId: activeAgent.id,
contentLength: content.length,
});
if (!sessionId) {
const session = await createSession.mutateAsync({
@@ -187,9 +193,20 @@ export function ChatWindow() {
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
const result = await api.sendChatMessage(sessionId, content);
setPendingTask(result.task_id);
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
taskId: result.task_id,
});
// Seed pending-task optimistically so the spinner shows instantly —
// the WS chat:message handler will invalidate + refetch to confirm.
qc.setQueryData(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
},
[
@@ -197,38 +214,88 @@ export function ChatWindow() {
activeAgent,
createSession,
setActiveSession,
setPendingTask,
qc,
],
);
const handleStop = useCallback(async () => {
if (!pendingTaskId) return;
if (!pendingTaskId) {
apiLogger.debug("cancelTask skipped: no pending task");
return;
}
apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId });
try {
await api.cancelTaskById(pendingTaskId);
} catch {
apiLogger.info("cancelTask.success", { taskId: pendingTaskId });
} catch (err) {
// Task may already be completed
apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err });
}
if (activeSessionId) {
// Clear pending immediately; WS task:cancelled will confirm.
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
}
clearTimeline();
setPendingTask(null);
}, [pendingTaskId, activeSessionId, clearTimeline, setPendingTask, qc]);
}, [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);
},
[setSelectedAgentId, setActiveSession],
[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 windowRef = useRef<HTMLDivElement>(null);
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
const hasMessages = messages.length > 0 || timelineItems.length > 0;
// 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 && boundsReady;
@@ -248,115 +315,111 @@ export function ChatWindow() {
return (
<div ref={windowRef} className={containerClass} style={containerStyle}>
<ChatResizeHandles onDragStart={startDrag} />
{/* Header */}
{!showHistory && (
<div className="flex items-center justify-between border-b px-4 py-2.5">
<AgentSelector
{/* 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">New chat</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"
onClick={toggleExpand}
/>
}
>
{isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="top">
{isAtMax ? "Restore" : "Expand"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={handleMinimize}
/>
}
>
<Minus />
</TooltipTrigger>
<TooltipContent side="top">Minimize</TooltipContent>
</Tooltip>
</div>
</div>
{/* Messages / skeleton / empty state */}
{showSkeleton ? (
<ChatMessageSkeleton />
) : hasMessages ? (
<ChatMessageList
messages={messages}
pendingTaskId={pendingTaskId}
isWaiting={!!pendingTaskId}
/>
) : (
<EmptyState
agentName={activeAgent?.name}
onPickPrompt={(text) => handleSend(text)}
/>
)}
{/* Input — disabled for archived sessions */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
agentName={activeAgent?.name}
leftAdornment={
<AgentDropdown
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setShowHistory(true)}
/>
}
>
<History />
</TooltipTrigger>
<TooltipContent side="bottom">Chat history</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => {
setActiveSession(null);
clearTimeline();
setPendingTask(null);
}}
/>
}
>
<Plus />
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={toggleExpand}
/>
}
>
{isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="bottom">
{isAtMax ? "Restore" : "Expand"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setOpen(false)}
/>
}
>
<Minus />
</TooltipTrigger>
<TooltipContent side="bottom">Minimize</TooltipContent>
</Tooltip>
</div>
</div>
)}
{showHistory ? (
<ChatSessionHistory />
) : (
<>
{/* Messages or Empty State */}
{hasMessages ? (
<ChatMessageList
messages={messages}
timelineItems={timelineItems}
isWaiting={!!pendingTaskId}
/>
) : (
<EmptyState agentName={activeAgent?.name} />
)}
{/* Input — disabled for archived sessions */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
/>
</>
)}
}
/>
</div>
);
}
function AgentSelector({
/**
* 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,
@@ -367,58 +430,54 @@ function AgentSelector({
userId: string | undefined;
onSelect: (agent: Agent) => void;
}) {
// 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-sm text-muted-foreground">No agents</span>;
return <span className="text-xs text-muted-foreground">No agents</span>;
}
if (agents.length <= 1) {
return (
<div className="flex items-center gap-2">
<AgentAvatarSmall agent={activeAgent} />
<span className="text-sm font-medium">{activeAgent.name}</span>
</div>
);
}
const myAgents = agents.filter((a) => a.owner_id === userId);
const othersAgents = agents.filter((a) => a.owner_id !== userId);
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent aria-expanded:bg-accent">
<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">
<AgentAvatarSmall agent={activeAgent} />
<span className="text-sm font-medium">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground" />
<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" className="max-h-60 w-auto max-w-56">
{myAgents.length > 0 && (
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
{mine.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>My Agents</DropdownMenuLabel>
{myAgents.map((agent) => (
<DropdownMenuItem
<DropdownMenuLabel>My agents</DropdownMenuLabel>
{mine.map((agent) => (
<AgentMenuItem
key={agent.id}
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate">{agent.name}</span>
</DropdownMenuItem>
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
{myAgents.length > 0 && othersAgents.length > 0 && <DropdownMenuSeparator />}
{othersAgents.length > 0 && (
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
{others.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Others</DropdownMenuLabel>
{othersAgents.map((agent) => (
<DropdownMenuItem
{others.map((agent) => (
<AgentMenuItem
key={agent.id}
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate">{agent.name}</span>
</DropdownMenuItem>
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
@@ -427,28 +486,142 @@ function AgentSelector({
);
}
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"
>
<AgentAvatarSmall agent={agent} />
<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 agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const activeSession = sessions.find((s) => s.id === activeSessionId);
const title = activeSession?.title?.trim() || "New chat";
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
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 && <AgentAvatarSmall agent={triggerAgent} />}
<span className="truncate text-sm font-medium">{title}</span>
<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">
No previous chats
</div>
) : (
sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
return (
<DropdownMenuItem
key={session.id}
onClick={() => onSelectSession(session)}
className="flex min-w-0 items-center gap-2"
>
{agent ? (
<AgentAvatarSmall agent={agent} />
) : (
<span className="size-6 shrink-0" />
)}
<span className="truncate flex-1 text-sm">
{session.title?.trim() || "New chat"}
</span>
{session.has_unread && (
<span className="size-1.5 shrink-0 rounded-full bg-brand" />
)}
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AgentAvatarSmall({ agent }: { agent: Agent }) {
return (
<Avatar className="size-5">
<Avatar className="size-6">
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3" />
<Bot className="size-3.5" />
</AvatarFallback>
</Avatar>
);
}
function EmptyState({ agentName }: { agentName?: string }) {
/**
* Three starter prompts shown on the empty state. Tapping one sends it
* immediately — ChatGPT-style — because the point is showing users what
* this chat is for: operating on the workspace, not open-ended Q&A.
*/
const STARTER_PROMPTS: { icon: string; text: string }[] = [
{ icon: "📋", text: "List my open tasks by priority" },
{ icon: "📝", text: "Summarize what I did today" },
{ icon: "💡", text: "Plan what to work on next" },
];
function EmptyState({
agentName,
onPickPrompt,
}: {
agentName?: string;
onPickPrompt: (text: string) => void;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8">
<Send className="size-8 text-muted-foreground/50" />
<div className="text-center">
<h3 className="text-base font-semibold">Welcome to Multica</h3>
<p className="mt-1 text-sm text-muted-foreground">
{agentName
? `Chat with ${agentName} or ask anything`
: "Ask anything or tell Multica what you need"}
</p>
<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 ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
</h3>
<p className="text-sm text-muted-foreground">Try asking</p>
</div>
<div className="w-full max-w-xs space-y-2">
{STARTER_PROMPTS.map((prompt) => (
<button
key={prompt.text}
type="button"
onClick={() => onPickPrompt(prompt.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">{prompt.icon}</span>
{prompt.text}
</button>
))}
</div>
</div>
);

View File

@@ -69,6 +69,10 @@ interface ContentEditorProps {
onSubmit?: () => void;
onBlur?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
/** Show the floating formatting toolbar on text selection. Defaults true. */
showBubbleMenu?: boolean;
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
submitOnEnter?: boolean;
}
interface ContentEditorRef {
@@ -96,6 +100,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onSubmit,
onBlur,
onUploadFile,
showBubbleMenu = true,
submitOnEnter = false,
},
ref,
) {
@@ -125,6 +131,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
queryClient,
onSubmitRef,
onUploadFileRef,
submitOnEnter,
}),
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
@@ -240,7 +247,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{editable && <EditorBubbleMenu editor={editor} />}
{editable && showBubbleMenu && <EditorBubbleMenu editor={editor} />}
<LinkHoverCard {...hover} />
</div>
);

View File

@@ -86,6 +86,8 @@ export interface EditorExtensionsOptions {
onUploadFileRef?: RefObject<
((file: File) => Promise<UploadResult | null>) | undefined
>;
/** When true, bare Enter also submits (chat-style). Default false. */
submitOnEnter?: boolean;
}
export function createEditorExtensions(
@@ -126,7 +128,15 @@ export function createEditorExtensions(
Typography,
Placeholder.configure({ placeholder: placeholderText }),
createMarkdownPasteExtension(),
createSubmitExtension(() => options.onSubmitRef?.current?.()),
createSubmitExtension(
() => {
const fn = options.onSubmitRef?.current;
if (!fn) return false; // no submit wired — let default Enter insert newline
fn();
return true;
},
{ submitOnEnter: options.submitOnEnter ?? false },
),
createFileUploadExtension(options.onUploadFileRef!),
);
}

View File

@@ -1,15 +1,36 @@
import { Extension } from "@tiptap/core";
export function createSubmitExtension(onSubmit: () => void) {
/**
* `onSubmit` must return true when it actually handled the event and false
* when there's no submit handler wired up. That lets us fall through to the
* default Enter behaviour — inserting a newline — when appropriate.
*
* `submitOnEnter` — when true, bare Enter also submits (chat-style). When
* false, only Mod-Enter submits and bare Enter keeps its default (newline).
*/
export function createSubmitExtension(
onSubmit: () => boolean,
{ submitOnEnter }: { submitOnEnter: boolean },
) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
const shortcuts: Record<string, () => boolean> = {
"Mod-Enter": () => onSubmit(),
};
if (submitOnEnter) {
shortcuts.Enter = () => {
const editor = this.editor;
// IME guard — never submit while composing a multi-key input
// (Chinese pinyin, Japanese kana, etc). `view.composing` is set
// by ProseMirror between compositionstart and compositionend.
if (editor.view.composing) return false;
// Let Enter insert a newline inside a code block.
if (editor.isActive("codeBlock")) return false;
return onSubmit();
};
}
return shortcuts;
},
});
}

View File

@@ -367,12 +367,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const recordVisit = useRecentIssuesStore((s) => s.recordVisit);
useEffect(() => {
if (issue) {
recordVisit({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
});
recordVisit(issue.id);
}
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -149,6 +149,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",

View File

@@ -2,6 +2,7 @@
import { useRef, useState } from "react";
import { useNavigation } from "../navigation";
import { useImmersiveMode } from "../platform";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
@@ -24,6 +25,11 @@ import {
} from "../workspace/slug";
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
// This modal is full-screen, so it covers the app titlebar. On macOS desktop
// we hide the traffic lights for its lifetime so the Back button in the top-
// left corner isn't stolen by the native controls' hit-test. No-op elsewhere.
useImmersiveMode();
const router = useNavigation();
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
@@ -83,13 +89,24 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
}}
>
<DialogContent
finalFocus={false}
showCloseButton={false}
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col items-center justify-center rounded-none bg-background ring-0 shadow-none"
>
{/* Top drag region — restores window-drag ability that the full-screen
modal would otherwise swallow. Transparent; web browsers ignore the
-webkit-app-region property, so this is safe cross-platform. */}
<div
aria-hidden
className="absolute inset-x-0 top-0 h-10"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<Button
variant="ghost"
size="sm"
className="absolute top-6 left-6 text-muted-foreground"
className="absolute top-12 left-12 text-muted-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" />

View File

@@ -59,6 +59,8 @@ function getOnboardingIssues(): OnboardingIssueDef[] {
description: [
"Skills are reusable instructions that make agents better at recurring tasks — deployments, code reviews, migrations, etc.",
"",
"**Note:** Skills already installed in your local runtime (e.g., `.claude/skills/`) are automatically available to agents — no need to re-upload them. Workspace skills here are for sharing knowledge across your team.",
"",
"**Steps:**",
"1. Go to **Skills** in the sidebar",
"2. Click **New Skill**",

View File

@@ -32,7 +32,8 @@
"./search": "./search/index.ts",
"./chat": "./chat/index.ts",
"./settings": "./settings/index.ts",
"./onboarding": "./onboarding/index.ts"
"./onboarding": "./onboarding/index.ts",
"./platform": "./platform/index.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",

View File

@@ -0,0 +1 @@
export { useImmersiveMode } from "./use-immersive-mode";

View File

@@ -0,0 +1,28 @@
import { useEffect } from "react";
type ImmersiveCapableAPI = {
setImmersiveMode?: (immersive: boolean) => Promise<void> | void;
};
function getDesktopAPI(): ImmersiveCapableAPI | undefined {
if (typeof window === "undefined") return undefined;
return (window as unknown as { desktopAPI?: ImmersiveCapableAPI }).desktopAPI;
}
/**
* Enter "immersive" mode for the lifetime of the component that calls it.
*
* On macOS desktop this hides the traffic-light window controls so full-screen
* modals (create-workspace, onboarding, etc.) can place UI in the top-left
* corner without fighting the native controls' hit-test. On web or non-macOS
* desktop this is a no-op.
*/
export function useImmersiveMode(): void {
useEffect(() => {
const api = getDesktopAPI();
api?.setImmersiveMode?.(true);
return () => {
api?.setImmersiveMode?.(false);
};
}, []);
}

View File

@@ -39,9 +39,22 @@ function getCliVersion(metadata: Record<string, unknown>): string | null {
return null;
}
function getLaunchedBy(metadata: Record<string, unknown>): string | null {
if (
metadata &&
typeof metadata.launched_by === "string" &&
metadata.launched_by
) {
return metadata.launched_by;
}
return null;
}
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
const cliVersion =
runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null;
const launchedBy =
runtime.runtime_mode === "local" ? getLaunchedBy(runtime.metadata) : null;
const user = useAuthStore((s) => s.user);
const wsId = useWorkspaceId();
@@ -146,6 +159,7 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
runtimeId={runtime.id}
currentVersion={cliVersion}
isOnline={runtime.status === "online"}
launchedBy={launchedBy}
/>
</div>
)}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import React, { useState, useCallback } from "react";
import { Server } from "lucide-react";
import { useDefaultLayout } from "react-resizable-panels";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -20,7 +20,12 @@ import { RuntimeDetail } from "./runtime-detail";
type RuntimeFilter = "mine" | "all";
export default function RuntimesPage() {
interface RuntimesPageProps {
/** Desktop-only slot rendered above the runtime list (e.g. local daemon card) */
topSlot?: React.ReactNode;
}
export default function RuntimesPage({ topSlot }: RuntimesPageProps = {}) {
const isLoading = useAuthStore((s) => s.isLoading);
const wsId = useWorkspaceId();
const qc = useQueryClient();
@@ -86,43 +91,46 @@ export default function RuntimesPage() {
}
return (
<ResizablePanelGroup
orientation="horizontal"
className="flex-1 min-h-0"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel
id="list"
defaultSize={280}
minSize={240}
maxSize={400}
groupResizeBehavior="preserve-pixel-size"
<div className="flex flex-1 min-h-0 flex-col">
{topSlot}
<ResizablePanelGroup
orientation="horizontal"
className="flex-1 min-h-0"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<RuntimeList
runtimes={runtimes}
selectedId={effectiveSelectedId}
onSelect={setSelectedId}
filter={filter}
onFilterChange={setFilter}
ownerFilter={ownerFilter}
onOwnerFilterChange={setOwnerFilter}
updatableIds={updatableIds}
/>
</ResizablePanel>
<ResizablePanel
id="list"
defaultSize={280}
minSize={240}
maxSize={400}
groupResizeBehavior="preserve-pixel-size"
>
<RuntimeList
runtimes={runtimes}
selectedId={effectiveSelectedId}
onSelect={setSelectedId}
filter={filter}
onFilterChange={setFilter}
ownerFilter={ownerFilter}
onOwnerFilterChange={setOwnerFilter}
updatableIds={updatableIds}
/>
</ResizablePanel>
<ResizableHandle />
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{selected ? (
<RuntimeDetail key={selected.id} runtime={selected} />
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Server className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select a runtime to view details</p>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
<ResizablePanel id="detail" minSize="50%">
{selected ? (
<RuntimeDetail key={selected.id} runtime={selected} />
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Server className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select a runtime to view details</p>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -78,13 +78,22 @@ interface UpdateSectionProps {
runtimeId: string;
currentVersion: string | null;
isOnline: boolean;
/**
* Non-null when the daemon process was spawned by a managed launcher
* (e.g. "desktop" for the Electron app). In that case the CLI binary
* is shipped and upgraded by the launcher itself, so in-app self-update
* is disabled — upgrading would be clobbered on the next launch anyway.
*/
launchedBy?: string | null;
}
export function UpdateSection({
runtimeId,
currentVersion,
isOnline,
launchedBy,
}: UpdateSectionProps) {
const isManaged = launchedBy === "desktop";
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [status, setStatus] = useState<RuntimeUpdateStatus | null>(null);
const [error, setError] = useState("");
@@ -165,33 +174,44 @@ export function UpdateSection({
{currentVersion ?? "unknown"}
</span>
{!hasUpdate && currentVersion && latestVersion && !status && (
<span className="inline-flex items-center gap-1 text-xs text-success">
<Check className="h-3 w-3" />
Latest
</span>
)}
{hasUpdate && !status && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs font-mono text-info">
{latestVersion}
</span>
<span className="text-xs text-muted-foreground">available</span>
</>
)}
{hasUpdate && isOnline && !status && (
<Button
variant="outline"
size="xs"
onClick={handleUpdate}
disabled={updating}
{isManaged ? (
<span
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
title="The CLI binary is managed by Multica Desktop — update Desktop to upgrade the CLI."
>
<ArrowUpCircle className="h-3 w-3" />
Update
</Button>
Managed by Desktop
</span>
) : (
<>
{!hasUpdate && currentVersion && latestVersion && !status && (
<span className="inline-flex items-center gap-1 text-xs text-success">
<Check className="h-3 w-3" />
Latest
</span>
)}
{hasUpdate && !status && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs font-mono text-info">
{latestVersion}
</span>
<span className="text-xs text-muted-foreground">available</span>
</>
)}
{hasUpdate && isOnline && !status && (
<Button
variant="outline"
size="xs"
onClick={handleUpdate}
disabled={updating}
>
<ArrowUpCircle className="h-3 w-3" />
Update
</Button>
)}
</>
)}
{config && Icon && (

View File

@@ -5,10 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { SearchCommand } from "./search-command";
import { useSearchStore } from "./search-store";
const { mockPush, mockSearchIssues, mockSearchProjects } = vi.hoisted(() => ({
const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchIssues: vi.fn(),
mockSearchProjects: vi.fn(),
mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> },
mockAllIssues: { current: [] as Array<Record<string, unknown>> },
}));
vi.mock("@multica/core/api", () => ({
@@ -19,12 +21,24 @@ vi.mock("@multica/core/api", () => ({
}));
vi.mock("@multica/core/issues/stores", () => ({
useRecentIssuesStore: (selector?: (state: { items: [] }) => unknown) => {
const state = { items: [] as [] };
useRecentIssuesStore: (selector?: (state: { items: typeof mockRecentItems.current }) => unknown) => {
const state = { items: mockRecentItems.current };
return selector ? selector(state) : state;
},
}));
vi.mock("@multica/core", () => ({
useWorkspaceId: () => "ws-test",
}));
vi.mock("@multica/core/issues/queries", () => ({
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockAllIssues.current }),
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: mockPush,
@@ -36,6 +50,8 @@ describe("SearchCommand", () => {
mockPush.mockReset();
mockSearchIssues.mockReset().mockResolvedValue({ issues: [] });
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
mockRecentItems.current = [];
mockAllIssues.current = [];
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
Element.prototype.scrollIntoView = vi.fn();
@@ -97,4 +113,39 @@ describe("SearchCommand", () => {
expect(mockPush).toHaveBeenCalledWith("/settings");
expect(useSearchStore.getState().open).toBe(false);
});
it("renders recent issues from query cache joined with store visit records", () => {
mockRecentItems.current = [
{ id: "issue-1", visitedAt: 1000 },
{ id: "issue-2", visitedAt: 900 },
];
mockAllIssues.current = [
{ id: "issue-1", identifier: "MUL-1", title: "First issue", status: "todo" },
{ id: "issue-2", identifier: "MUL-2", title: "Second issue", status: "done" },
];
render(<SearchCommand />);
expect(screen.getByText("Recent")).toBeInTheDocument();
expect(screen.getByText("First issue")).toBeInTheDocument();
expect(screen.getByText("MUL-1")).toBeInTheDocument();
expect(screen.getByText("Second issue")).toBeInTheDocument();
expect(screen.getByText("MUL-2")).toBeInTheDocument();
});
it("filters out recent items not present in query cache", () => {
mockRecentItems.current = [
{ id: "issue-1", visitedAt: 1000 },
{ id: "deleted-issue", visitedAt: 900 },
];
mockAllIssues.current = [
{ id: "issue-1", identifier: "MUL-1", title: "Existing issue", status: "in_progress" },
];
render(<SearchCommand />);
expect(screen.getByText("Recent")).toBeInTheDocument();
expect(screen.getByText("Existing issue")).toBeInTheDocument();
expect(screen.queryByText("deleted-issue")).not.toBeInTheDocument();
});
});

View File

@@ -17,9 +17,12 @@ import {
type LucideIcon,
} from "lucide-react";
import { Command as CommandPrimitive } from "cmdk";
import { useQuery } from "@tanstack/react-query";
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useRecentIssuesStore } from "@multica/core/issues/stores";
import { issueListOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core";
import { StatusIcon } from "../issues/components";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
@@ -97,7 +100,18 @@ export function SearchCommand() {
const { push } = useNavigation();
const open = useSearchStore((s) => s.open);
const setOpen = useSearchStore((s) => s.setOpen);
const recentIssues = useRecentIssuesStore((s) => s.items);
const recentItems = useRecentIssuesStore((s) => s.items);
const wsId = useWorkspaceId();
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
const recentIssues = useMemo(() => {
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
return recentItems.flatMap((item) => {
const issue = issueMap.get(item.id);
return issue ? [issue] : [];
});
}, [recentItems, allIssues]);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
const [isLoading, setIsLoading] = useState(false);

View File

@@ -1 +1,2 @@
export { SettingsPage } from "./settings-page";
export type { ExtraSettingsTab } from "./settings-page";

View File

@@ -1,5 +1,6 @@
"use client";
import React from "react";
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
import { useWorkspaceStore } from "@multica/core/workspace";
@@ -22,7 +23,19 @@ const workspaceTabs = [
{ value: "members", label: "Members", icon: Users },
];
export function SettingsPage() {
export interface ExtraSettingsTab {
value: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
content: React.ReactNode;
}
interface SettingsPageProps {
/** Additional tabs injected by platform (e.g. desktop daemon settings) */
extraAccountTabs?: ExtraSettingsTab[];
}
export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
return (
@@ -41,6 +54,12 @@ export function SettingsPage() {
{tab.label}
</TabsTrigger>
))}
{extraAccountTabs?.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
{/* Workspace group */}
<span className="px-2 pb-1 pt-4 text-xs font-medium text-muted-foreground truncate">
@@ -64,6 +83,9 @@ export function SettingsPage() {
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
{extraAccountTabs?.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>{tab.content}</TabsContent>
))}
</div>
</div>
</Tabs>

View File

@@ -1 +1,2 @@
export { SettingsPage } from "./components";
export type { ExtraSettingsTab } from "./components";

View File

@@ -96,9 +96,9 @@ function CreateSkillDialog({
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Skill</DialogTitle>
<DialogTitle>Add Workspace Skill</DialogTitle>
<DialogDescription>
Create a new skill or import from ClawHub / Skills.sh.
Create a new skill or import from ClawHub / Skills.sh. Workspace skills are shared with your team and automatically injected into agent runs.
</DialogDescription>
</DialogHeader>
@@ -744,9 +744,9 @@ export default function SkillsPage() {
{skills.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Skills define reusable instructions for agents.
<p className="mt-3 text-sm text-muted-foreground">No workspace skills yet</p>
<p className="mt-1 text-xs text-muted-foreground text-center max-w-[280px]">
Workspace skills are shared across your team and injected into agent runs. Skills already installed in your local runtime are used automatically.
</p>
<Button
onClick={() => setShowCreate(true)}
@@ -788,6 +788,9 @@ export default function SkillsPage() {
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select a skill to view details</p>
<p className="mt-1 text-xs text-center max-w-[260px]">
Workspace skills supplement your local skills and are shared across the team.
</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"

View File

@@ -99,13 +99,16 @@ func runAuthLoginBrowser(cmd *cobra.Command) error {
appURL := resolveAppURL(cmd)
// Determine the callback host from the configured app URL.
// For self-hosted setups where the browser is on a different machine,
// we need to use the server's reachable hostname instead of localhost.
// For self-hosted setups where the browser is on a different machine
// (e.g. Multica running on a LAN server), use the server's private IP
// so the browser can reach the CLI's local HTTP server.
// For production (public hostnames like multica.ai), keep localhost —
// the browser and CLI are on the same machine.
callbackHost := "localhost"
bindAddr := "127.0.0.1"
if parsed, err := url.Parse(appURL); err == nil {
h := parsed.Hostname()
if h != "" && h != "localhost" && h != "127.0.0.1" {
if ip := net.ParseIP(h); ip != nil && ip.IsPrivate() {
callbackHost = h
bindAddr = "0.0.0.0"
}

View File

@@ -272,6 +272,9 @@ func runDaemonForeground(cmd *cobra.Command) error {
return err
}
cfg.CLIVersion = version
// Set by the Electron Desktop app when it spawns the CLI so the server
// can mark those runtimes as "managed" and hide CLI self-update UI.
cfg.LaunchedBy = os.Getenv("MULTICA_LAUNCHED_BY")
ctx, stop := notifyShutdownContext(context.Background())
defer stop()

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
)
@@ -22,6 +23,9 @@ var rootCmd = &cobra.Command{
}
func init() {
rootCmd.Version = fmt.Sprintf("%s (commit: %s, built: %s)\ngo: %s, os/arch: %s/%s", version, commit, date, runtime.Version(), runtime.GOOS, runtime.GOARCH)
rootCmd.SetVersionTemplate("multica {{.Version}}\n")
rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)")
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
rootCmd.PersistentFlags().String("profile", "", "Configuration profile name (e.g. dev) — isolates config, daemon state, and workspaces")

View File

@@ -241,6 +241,20 @@ func TestCommentTriggerOnComment(t *testing.T) {
}
})
t.Run("reply to member thread after agent replied triggers agent", func(t *testing.T) {
clearTasks(t, issueID)
// Member starts a thread (top-level comment).
threadID := postComment(t, issueID, "Please fix this bug", nil)
clearTasks(t, issueID)
// Agent replies in the thread.
postCommentAsAgent(t, issueID, "Working on it, found the root cause.", agentID, strPtr(threadID))
// Member follows up in the same thread without @mentioning the agent.
postComment(t, issueID, "Great, please also check the edge case", strPtr(threadID))
if n := countPendingTasks(t, issueID); n != 1 {
t.Errorf("expected 1 pending task (agent participated in thread), got %d", n)
}
})
t.Run("reply to member thread mentioning assignee triggers agent", func(t *testing.T) {
clearTasks(t, issueID)
// Member starts a thread.

View File

@@ -321,8 +321,11 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Delete("/", h.ArchiveChatSession)
r.Post("/messages", h.SendChatMessage)
r.Get("/messages", h.ListChatMessages)
r.Get("/pending-task", h.GetPendingChatTask)
r.Post("/read", h.MarkChatSessionRead)
})
})
r.Get("/api/chat/pending-tasks", h.ListPendingChatTasks)
// Inbox
r.Route("/api/inbox", func(r chi.Router) {

View File

@@ -18,11 +18,45 @@ type WatchedWorkspace struct {
// CLIConfig holds persistent CLI settings.
type CLIConfig struct {
ServerURL string `json:"server_url,omitempty"`
AppURL string `json:"app_url,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
Token string `json:"token,omitempty"`
WatchedWorkspaces []WatchedWorkspace `json:"watched_workspaces,omitempty"`
ServerURL string `json:"server_url,omitempty"`
AppURL string `json:"app_url,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
Token string `json:"token,omitempty"`
WatchedWorkspaces []WatchedWorkspace `json:"watched_workspaces,omitempty"`
// UnwatchedWorkspaces is a denylist of workspace IDs the user has
// explicitly opted out of. The daemon's periodic sync from the API
// respects this list and won't re-add excluded workspaces.
UnwatchedWorkspaces []string `json:"unwatched_workspaces,omitempty"`
}
// IsUnwatched reports whether the given workspace ID is in the denylist.
func (c *CLIConfig) IsUnwatched(id string) bool {
for _, u := range c.UnwatchedWorkspaces {
if u == id {
return true
}
}
return false
}
// AddUnwatchedWorkspace adds an ID to the denylist. Returns true if added.
func (c *CLIConfig) AddUnwatchedWorkspace(id string) bool {
if c.IsUnwatched(id) {
return false
}
c.UnwatchedWorkspaces = append(c.UnwatchedWorkspaces, id)
return true
}
// RemoveUnwatchedWorkspace removes an ID from the denylist. Returns true if removed.
func (c *CLIConfig) RemoveUnwatchedWorkspace(id string) bool {
for i, u := range c.UnwatchedWorkspaces {
if u == id {
c.UnwatchedWorkspaces = append(c.UnwatchedWorkspaces[:i], c.UnwatchedWorkspaces[i+1:]...)
return true
}
}
return false
}
// AddWatchedWorkspace adds a workspace to the watch list. Returns true if added.

View File

@@ -32,6 +32,7 @@ type Config struct {
DeviceName string
RuntimeName string
CLIVersion string // multica CLI version (e.g. "0.1.13")
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
Profile string // profile name (empty = default)
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)

View File

@@ -86,6 +86,17 @@ func (d *Daemon) Run(ctx context.Context) error {
return err
}
// If no runtimes yet (empty watched list), run one sync cycle to discover
// workspaces from the API before giving up. workspaceSyncLoop normally
// handles this, but the runtime check below would fail before it runs.
if len(d.allRuntimeIDs()) == 0 {
d.syncWorkspacesFromAPI(ctx)
// syncWorkspacesFromAPI writes to config; reload and register.
if err := d.loadWatchedWorkspaces(ctx); err != nil {
return err
}
}
runtimeIDs := d.allRuntimeIDs()
if len(runtimeIDs) == 0 {
return fmt.Errorf("no runtimes registered")
@@ -156,8 +167,13 @@ func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error {
return fmt.Errorf("load CLI config: %w", err)
}
// It's fine to start with an empty watched list — workspaceSyncLoop runs
// immediately on startup and will populate the list from the server. The
// daemon also accepts HTTP POST /watch for explicit adds from clients
// like the Desktop app.
if len(cfg.WatchedWorkspaces) == 0 {
return fmt.Errorf("no watched workspaces configured: run 'multica workspace watch <id>' to add one")
d.logger.Info("no watched workspaces in config; workspaceSyncLoop will populate from API")
return nil
}
var registered int
@@ -263,6 +279,7 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
"daemon_id": d.cfg.DaemonID,
"device_name": d.cfg.DeviceName,
"cli_version": d.cfg.CLIVersion,
"launched_by": d.cfg.LaunchedBy,
"runtimes": runtimes,
}
@@ -350,6 +367,9 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) {
var added int
for _, ws := range workspaces {
if cfg.IsUnwatched(ws.ID) {
continue // user explicitly opted out
}
if cfg.AddWatchedWorkspace(ws.ID, ws.Name) {
added++
d.logger.Info("workspace sync: discovered new workspace", "workspace_id", ws.ID, "name", ws.Name)
@@ -562,6 +582,19 @@ func (d *Daemon) handlePing(ctx context.Context, rt Runtime, pingID string) {
// handleUpdate performs the CLI update when triggered by the server via heartbeat.
func (d *Daemon) handleUpdate(ctx context.Context, runtimeID string, update *PendingUpdate) {
// Desktop-managed daemons share their CLI binary with the Electron app,
// which is responsible for shipping and replacing it. Letting the daemon
// self-update would just get overwritten on the next Desktop launch and
// could brick the embedded binary mid-update. Refuse cleanly.
if d.cfg.LaunchedBy == "desktop" {
d.logger.Info("refusing CLI self-update: daemon is managed by Desktop", "runtime_id", runtimeID, "update_id", update.ID)
d.client.ReportUpdateResult(ctx, runtimeID, update.ID, map[string]any{
"status": "failed",
"error": "CLI is managed by Multica Desktop — update the Desktop app to upgrade the CLI",
})
return
}
// Prevent concurrent update attempts.
if !d.updating.CompareAndSwap(false, true) {
d.logger.Warn("update already in progress, ignoring", "runtime_id", runtimeID, "update_id", update.ID)

View File

@@ -7,8 +7,10 @@ import (
"net"
"net/http"
"os"
"strings"
"time"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/internal/daemon/repocache"
)
@@ -49,6 +51,19 @@ type repoCheckoutRequest struct {
TaskID string `json:"task_id"`
}
// watchWorkspaceRequest is the body of a POST /watch request.
type watchWorkspaceRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
}
// watchedWorkspaceItem is one entry in the GET /watch response.
type watchedWorkspaceItem struct {
ID string `json:"id"`
Name string `json:"name"`
Runtime int `json:"runtime_count"`
}
// serveHealth runs the health HTTP server on the given listener.
// Blocks until ctx is cancelled.
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
@@ -84,6 +99,31 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/watch", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
d.handleListWatched(w, r)
case http.MethodPost:
d.handleWatchWorkspace(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// DELETE /watch/{workspace_id}
mux.HandleFunc("/watch/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/watch/")
if id == "" {
http.Error(w, "workspace_id is required in path", http.StatusBadRequest)
return
}
d.handleUnwatchWorkspace(w, r, id)
})
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -138,3 +178,135 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
d.logger.Warn("health server error", "error", err)
}
}
// handleListWatched returns the daemon's current watched workspaces merged
// with the persisted config so clients can reflect "what is watched" and
// "what has been explicitly opted out".
func (d *Daemon) handleListWatched(w http.ResponseWriter, _ *http.Request) {
cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile)
if err != nil {
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
return
}
d.mu.Lock()
items := make([]watchedWorkspaceItem, 0, len(cfg.WatchedWorkspaces))
for _, ws := range cfg.WatchedWorkspaces {
rc := 0
if state, ok := d.workspaces[ws.ID]; ok {
rc = len(state.runtimeIDs)
}
items = append(items, watchedWorkspaceItem{
ID: ws.ID,
Name: ws.Name,
Runtime: rc,
})
}
d.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"watched": items,
"unwatched": cfg.UnwatchedWorkspaces,
})
}
// handleWatchWorkspace registers a new workspace at runtime. Updates config
// (removes from denylist, adds to watched list), registers runtimes via the
// API, and records the result in daemon state. Idempotent.
func (d *Daemon) handleWatchWorkspace(w http.ResponseWriter, r *http.Request) {
var req watchWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if req.WorkspaceID == "" {
http.Error(w, "workspace_id is required", http.StatusBadRequest)
return
}
cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile)
if err != nil {
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
return
}
cfg.RemoveUnwatchedWorkspace(req.WorkspaceID)
cfg.AddWatchedWorkspace(req.WorkspaceID, req.Name)
if err := cli.SaveCLIConfigForProfile(cfg, d.cfg.Profile); err != nil {
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError)
return
}
// Skip registration if we're already tracking this workspace.
d.mu.Lock()
_, already := d.workspaces[req.WorkspaceID]
d.mu.Unlock()
if already {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"status": "already_watching"})
return
}
resp, err := d.registerRuntimesForWorkspace(r.Context(), req.WorkspaceID)
if err != nil {
d.logger.Error("watch: register failed", "workspace_id", req.WorkspaceID, "error", err)
http.Error(w, "register runtimes: "+err.Error(), http.StatusInternalServerError)
return
}
runtimeIDs := make([]string, len(resp.Runtimes))
for i, rt := range resp.Runtimes {
runtimeIDs[i] = rt.ID
}
d.mu.Lock()
d.workspaces[req.WorkspaceID] = &workspaceState{workspaceID: req.WorkspaceID, runtimeIDs: runtimeIDs}
for _, rt := range resp.Runtimes {
d.runtimeIndex[rt.ID] = rt
}
d.mu.Unlock()
if d.repoCache != nil && len(resp.Repos) > 0 {
go func(wsID string, repos []RepoData) {
if err := d.repoCache.Sync(wsID, repoDataToInfo(repos)); err != nil {
d.logger.Warn("repo cache sync failed", "workspace_id", wsID, "error", err)
}
}(req.WorkspaceID, resp.Repos)
}
d.logger.Info("watch: now watching workspace", "workspace_id", req.WorkspaceID, "name", req.Name, "runtimes", len(resp.Runtimes))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "watching",
"workspace_id": req.WorkspaceID,
"runtime_count": len(runtimeIDs),
})
}
// handleUnwatchWorkspace stops tracking a workspace and records the opt-out
// in the denylist so the periodic API sync won't revive it.
func (d *Daemon) handleUnwatchWorkspace(w http.ResponseWriter, _ *http.Request, id string) {
cfg, err := cli.LoadCLIConfigForProfile(d.cfg.Profile)
if err != nil {
http.Error(w, "load config: "+err.Error(), http.StatusInternalServerError)
return
}
cfg.RemoveWatchedWorkspace(id)
cfg.AddUnwatchedWorkspace(id)
if err := cli.SaveCLIConfigForProfile(cfg, d.cfg.Profile); err != nil {
http.Error(w, "save config: "+err.Error(), http.StatusInternalServerError)
return
}
d.mu.Lock()
if state, ok := d.workspaces[id]; ok {
for _, rid := range state.runtimeIDs {
delete(d.runtimeIndex, rid)
}
delete(d.workspaces, id)
}
d.mu.Unlock()
d.logger.Info("watch: stopped watching workspace", "workspace_id", id)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"status": "unwatched", "workspace_id": id})
}

View File

@@ -73,27 +73,55 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
var sessions []db.ChatSession
var err error
// Two call sites → two row types with identical shape. Collect into a
// common response slice via small per-branch loops.
var resp []ChatSessionResponse
if status == "all" {
sessions, err = h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
rows, err := h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
return
}
resp = make([]ChatSessionResponse, len(rows))
for i, s := range rows {
resp[i] = ChatSessionResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
AgentID: uuidToString(s.AgentID),
CreatorID: uuidToString(s.CreatorID),
Title: s.Title,
Status: s.Status,
HasUnread: s.HasUnread,
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
}
}
} else {
sessions, err = h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
rows, err := h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
return
}
resp := make([]ChatSessionResponse, len(sessions))
for i, s := range sessions {
resp[i] = chatSessionToResponse(s)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
return
}
resp = make([]ChatSessionResponse, len(rows))
for i, s := range rows {
resp[i] = ChatSessionResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
AgentID: uuidToString(s.AgentID),
CreatorID: uuidToString(s.CreatorID),
Title: s.Title,
Status: s.Status,
HasUnread: s.HasUnread,
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
}
}
}
writeJSON(w, http.StatusOK, resp)
}
@@ -273,6 +301,127 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// PendingChatTaskResponse is returned by GetPendingChatTask — either the
// current in-flight task's id/status, or an empty object when none is active.
type PendingChatTaskResponse struct {
TaskID string `json:"task_id,omitempty"`
Status string `json:"status,omitempty"`
}
// MarkChatSessionRead clears the session's unread_since (→ has_unread=false)
// and broadcasts chat:session_read so other devices of the same user drop
// their badges.
func (h *Handler) MarkChatSessionRead(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
ID: parseUUID(sessionID),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "chat session not found")
return
}
if uuidToString(session.CreatorID) != userID {
writeError(w, http.StatusForbidden, "not your chat session")
return
}
if err := h.Queries.MarkChatSessionRead(r.Context(), parseUUID(sessionID)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark session read")
return
}
h.publish(protocol.EventChatSessionRead, workspaceID, "member", userID, protocol.ChatSessionReadPayload{
ChatSessionID: sessionID,
})
w.WriteHeader(http.StatusNoContent)
}
// PendingChatTasksResponse is the aggregate view consumed by the FAB.
type PendingChatTasksResponse struct {
Tasks []PendingChatTaskItem `json:"tasks"`
}
type PendingChatTaskItem struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
ChatSessionID string `json:"chat_session_id"`
}
// ListPendingChatTasks returns every in-flight chat task owned by the current
// user in this workspace. Drives the FAB's "running" indicator when the chat
// window is closed (no per-session query is subscribed).
func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
rows, err := h.Queries.ListPendingChatTasksByCreator(r.Context(), db.ListPendingChatTasksByCreatorParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list pending chat tasks")
return
}
items := make([]PendingChatTaskItem, len(rows))
for i, row := range rows {
items[i] = PendingChatTaskItem{
TaskID: uuidToString(row.TaskID),
Status: row.Status,
ChatSessionID: uuidToString(row.ChatSessionID),
}
}
writeJSON(w, http.StatusOK, PendingChatTasksResponse{Tasks: items})
}
// GetPendingChatTask returns the most recent in-flight task (queued / dispatched
// / running) for a chat session. The frontend polls this on mount / session
// switch so pending UI state survives refresh and reopen.
func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
ID: parseUUID(sessionID),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "chat session not found")
return
}
if uuidToString(session.CreatorID) != userID {
writeError(w, http.StatusForbidden, "not your chat session")
return
}
task, err := h.Queries.GetPendingChatTask(r.Context(), parseUUID(sessionID))
if err != nil {
// No in-flight task — return an empty object, not an error.
writeJSON(w, http.StatusOK, PendingChatTaskResponse{})
return
}
writeJSON(w, http.StatusOK, PendingChatTaskResponse{
TaskID: uuidToString(task.ID),
Status: task.Status,
})
}
// ---------------------------------------------------------------------------
// Task cancellation (user-facing, with ownership check)
// ---------------------------------------------------------------------------
@@ -333,14 +482,16 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
// ---------------------------------------------------------------------------
type ChatSessionResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
AgentID string `json:"agent_id"`
CreatorID string `json:"creator_id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
AgentID string `json:"agent_id"`
CreatorID string `json:"creator_id"`
Title string `json:"title"`
Status string `json:"status"`
// Only populated by list endpoints — single-session fetches return false.
HasUnread bool `json:"has_unread"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ChatMessageResponse struct {

View File

@@ -262,7 +262,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// assignee — the user is continuing a member-to-member conversation.
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
!h.isReplyToMemberThread(parentComment, comment.Content, issue) {
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
// Resolve thread root: if the comment is a reply, agent should reply
// to the thread root (matching frontend behavior where all replies
// in a thread share the same top-level parent).
@@ -325,7 +325,9 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
// in the reply, still triggers on_comment as expected.
// If the parent (thread root) itself @mentions the assignee, the thread is
// considered a conversation with the agent, so replies are allowed to trigger.
func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issue db.Issue) bool {
// If the assigned agent has already replied in the thread, the member is
// conversing with the agent, so replies are allowed to trigger.
func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment, content string, issue db.Issue) bool {
if parent == nil {
return false // Not a reply — normal top-level comment
}
@@ -333,7 +335,8 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
return false // Thread started by an agent — allow trigger
}
// Thread was started by a member. Suppress on_comment unless the reply
// or the parent explicitly @mentions the assignee agent.
// or the parent explicitly @mentions the assignee agent, or the agent
// has already participated in this thread.
if !issue.AssigneeID.Valid {
return true // No assignee to mention
}
@@ -351,7 +354,18 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu
return false // Assignee mentioned in thread root — allow trigger
}
}
return true // Reply to member thread without mentioning agent — suppress
// Check if the assigned agent has already replied in this thread —
// if so, the member is continuing a conversation with the agent.
if h.Queries != nil {
hasReplied, err := h.Queries.HasAgentRepliedInThread(ctx, db.HasAgentRepliedInThreadParams{
ParentID: parent.ID,
AgentID: issue.AssigneeID,
})
if err == nil && hasReplied {
return false // Agent participated in thread — allow trigger
}
}
return true // Reply to member thread without agent participation — suppress
}
// enqueueMentionedAgentTasks parses @agent mentions from comment content and

View File

@@ -117,6 +117,7 @@ type DaemonRegisterRequest struct {
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
CLIVersion string `json:"cli_version"` // multica CLI version
LaunchedBy string `json:"launched_by"` // "desktop" when spawned by the Electron app
Runtimes []struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -200,6 +201,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
metadata, _ := json.Marshal(map[string]any{
"version": runtime.Version,
"cli_version": req.CLIVersion,
"launched_by": req.LaunchedBy,
})
registered, err := h.Queries.UpsertAgentRuntime(r.Context(), db.UpsertAgentRuntimeParams{

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"fmt"
"testing"
@@ -205,7 +206,7 @@ func TestIsReplyToMemberThread(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := h.isReplyToMemberThread(tt.parent, tt.content, issue)
got := h.isReplyToMemberThread(context.Background(), tt.parent, tt.content, issue)
if got != tt.want {
t.Errorf("isReplyToMemberThread() = %v, want %v", got, tt.want)
}
@@ -233,7 +234,7 @@ func TestOnCommentTriggerDecision(t *testing.T) {
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
shouldTrigger := func(parent *db.Comment, content string) bool {
return !h.commentMentionsOthersButNotAssignee(content, issue) &&
!h.isReplyToMemberThread(parent, content, issue)
!h.isReplyToMemberThread(context.Background(), parent, content, issue)
}
tests := []struct {

View File

@@ -10,24 +10,39 @@ import (
"github.com/lmittmann/tint"
)
// Init initializes the global slog logger with colored terminal output.
// Reads LOG_LEVEL env var (debug, info, warn, error). Default: debug.
// isTerminal reports whether the given file descriptor is connected to a
// terminal. Used to suppress ANSI color escapes when stderr is redirected
// to a file (e.g. daemon.log), so log files stay clean.
func isTerminal(f *os.File) bool {
fi, err := f.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
// Init initializes the global slog logger. Colors are enabled when stderr
// is a terminal and disabled otherwise. Reads LOG_LEVEL env var (debug,
// info, warn, error). Default: debug.
func Init() {
level := parseLevel(os.Getenv("LOG_LEVEL"))
handler := tint.NewHandler(os.Stderr, &tint.Options{
Level: level,
TimeFormat: "15:04:05.000",
NoColor: !isTerminal(os.Stderr),
})
slog.SetDefault(slog.New(handler))
}
// NewLogger creates a named slog logger with colored terminal output.
// Useful for standalone processes (daemon, migrate) that want a component prefix.
// NewLogger creates a named slog logger. Colors follow the same
// TTY-detection rule as Init. Useful for standalone processes (daemon,
// migrate) that want a component prefix.
func NewLogger(component string) *slog.Logger {
level := parseLevel(os.Getenv("LOG_LEVEL"))
handler := tint.NewHandler(os.Stderr, &tint.Options{
Level: level,
TimeFormat: "15:04:05.000",
NoColor: !isTerminal(os.Stderr),
})
return slog.New(handler).With("component", component)
}

View File

@@ -307,6 +307,14 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
TaskID: task.ID,
}); err != nil {
slog.Error("failed to save assistant chat message", "task_id", util.UUIDToString(task.ID), "error", err)
} else {
// Event-driven unread: stamp unread_since on the first unread
// assistant message. No-op if the session already has unread.
// If the user is actively viewing the session, the frontend's
// auto-mark-read effect will clear this within a tick.
if err := s.Queries.SetUnreadSinceIfNull(ctx, task.ChatSessionID); err != nil {
slog.Warn("failed to set unread_since", "chat_session_id", util.UUIDToString(task.ChatSessionID), "error", err)
}
}
}
s.Queries.UpdateChatSessionSession(ctx, db.UpdateChatSessionSessionParams{

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_agent_task_queue_chat_pending;
ALTER TABLE chat_session DROP COLUMN unread_since;

View File

@@ -0,0 +1,16 @@
-- Event-driven unread tracking for chat sessions.
--
-- Semantics: unread_since is the timestamp of the first unread assistant
-- message. It stays NULL while the session has no unread. It's SET when
-- an assistant reply lands and the column was NULL. It's RESET to NULL
-- when the user marks the session as read. Existing rows start as NULL,
-- meaning "no unread to track" — historic chats are not mass-flagged.
ALTER TABLE chat_session ADD COLUMN unread_since TIMESTAMPTZ;
-- GetPendingChatTask runs on every session open / switch and filters by
-- chat_session_id + in-flight status + orders by created_at. A partial
-- index on the in-flight subset keeps that query cheap as the queue grows.
CREATE INDEX IF NOT EXISTS idx_agent_task_queue_chat_pending
ON agent_task_queue (chat_session_id, created_at DESC)
WHERE chat_session_id IS NOT NULL
AND status IN ('queued', 'dispatched', 'running');

View File

@@ -56,7 +56,7 @@ func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessagePa
const createChatSession = `-- name: CreateChatSession :one
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title)
VALUES ($1, $2, $3, $4)
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since
`
type CreateChatSessionParams struct {
@@ -85,6 +85,7 @@ func (q *Queries) CreateChatSession(ctx context.Context, arg CreateChatSessionPa
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.UnreadSince,
)
return i, err
}
@@ -152,7 +153,7 @@ func (q *Queries) GetChatMessage(ctx context.Context, id pgtype.UUID) (ChatMessa
}
const getChatSession = `-- name: GetChatSession :one
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since FROM chat_session
WHERE id = $1
`
@@ -170,12 +171,13 @@ func (q *Queries) GetChatSession(ctx context.Context, id pgtype.UUID) (ChatSessi
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.UnreadSince,
)
return i, err
}
const getChatSessionInWorkspace = `-- name: GetChatSessionInWorkspace :one
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since FROM chat_session
WHERE id = $1 AND workspace_id = $2
`
@@ -198,6 +200,7 @@ func (q *Queries) GetChatSessionInWorkspace(ctx context.Context, arg GetChatSess
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.UnreadSince,
)
return i, err
}
@@ -221,10 +224,33 @@ func (q *Queries) GetLastChatTaskSession(ctx context.Context, chatSessionID pgty
return i, err
}
const getPendingChatTask = `-- name: GetPendingChatTask :one
SELECT id, status FROM agent_task_queue
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running')
ORDER BY created_at DESC
LIMIT 1
`
type GetPendingChatTaskRow struct {
ID pgtype.UUID `json:"id"`
Status string `json:"status"`
}
// Returns the most recent in-flight task for a chat session, if any.
// Used by the frontend to recover pending state after refresh / reopen.
func (q *Queries) GetPendingChatTask(ctx context.Context, chatSessionID pgtype.UUID) (GetPendingChatTaskRow, error) {
row := q.db.QueryRow(ctx, getPendingChatTask, chatSessionID)
var i GetPendingChatTaskRow
err := row.Scan(&i.ID, &i.Status)
return i, err
}
const listAllChatSessionsByCreator = `-- name: ListAllChatSessionsByCreator :many
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
WHERE workspace_id = $1 AND creator_id = $2
ORDER BY updated_at DESC
SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_id, cs.work_dir, cs.status, cs.created_at, cs.updated_at, cs.unread_since,
(cs.unread_since IS NOT NULL)::bool AS has_unread
FROM chat_session cs
WHERE cs.workspace_id = $1 AND cs.creator_id = $2
ORDER BY cs.updated_at DESC
`
type ListAllChatSessionsByCreatorParams struct {
@@ -232,15 +258,30 @@ type ListAllChatSessionsByCreatorParams struct {
CreatorID pgtype.UUID `json:"creator_id"`
}
func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllChatSessionsByCreatorParams) ([]ChatSession, error) {
type ListAllChatSessionsByCreatorRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
AgentID pgtype.UUID `json:"agent_id"`
CreatorID pgtype.UUID `json:"creator_id"`
Title string `json:"title"`
SessionID pgtype.Text `json:"session_id"`
WorkDir pgtype.Text `json:"work_dir"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
UnreadSince pgtype.Timestamptz `json:"unread_since"`
HasUnread bool `json:"has_unread"`
}
func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllChatSessionsByCreatorParams) ([]ListAllChatSessionsByCreatorRow, error) {
rows, err := q.db.Query(ctx, listAllChatSessionsByCreator, arg.WorkspaceID, arg.CreatorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ChatSession{}
items := []ListAllChatSessionsByCreatorRow{}
for rows.Next() {
var i ChatSession
var i ListAllChatSessionsByCreatorRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
@@ -252,6 +293,8 @@ func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllC
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.UnreadSince,
&i.HasUnread,
); err != nil {
return nil, err
}
@@ -297,9 +340,11 @@ func (q *Queries) ListChatMessages(ctx context.Context, chatSessionID pgtype.UUI
}
const listChatSessionsByCreator = `-- name: ListChatSessionsByCreator :many
SELECT id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at FROM chat_session
WHERE workspace_id = $1 AND creator_id = $2 AND status = 'active'
ORDER BY updated_at DESC
SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_id, cs.work_dir, cs.status, cs.created_at, cs.updated_at, cs.unread_since,
(cs.unread_since IS NOT NULL)::bool AS has_unread
FROM chat_session cs
WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
ORDER BY cs.updated_at DESC
`
type ListChatSessionsByCreatorParams struct {
@@ -307,15 +352,33 @@ type ListChatSessionsByCreatorParams struct {
CreatorID pgtype.UUID `json:"creator_id"`
}
func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSessionsByCreatorParams) ([]ChatSession, error) {
type ListChatSessionsByCreatorRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
AgentID pgtype.UUID `json:"agent_id"`
CreatorID pgtype.UUID `json:"creator_id"`
Title string `json:"title"`
SessionID pgtype.Text `json:"session_id"`
WorkDir pgtype.Text `json:"work_dir"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
UnreadSince pgtype.Timestamptz `json:"unread_since"`
HasUnread bool `json:"has_unread"`
}
// Returns active sessions with a boolean unread flag. Unread is strictly
// per-session: either the user has uncleared assistant replies in this
// session or they don't. Counting messages would be misleading.
func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSessionsByCreatorParams) ([]ListChatSessionsByCreatorRow, error) {
rows, err := q.db.Query(ctx, listChatSessionsByCreator, arg.WorkspaceID, arg.CreatorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ChatSession{}
items := []ListChatSessionsByCreatorRow{}
for rows.Next() {
var i ChatSession
var i ListChatSessionsByCreatorRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
@@ -327,6 +390,8 @@ func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSes
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.UnreadSince,
&i.HasUnread,
); err != nil {
return nil, err
}
@@ -338,6 +403,74 @@ func (q *Queries) ListChatSessionsByCreator(ctx context.Context, arg ListChatSes
return items, nil
}
const listPendingChatTasksByCreator = `-- name: ListPendingChatTasksByCreator :many
SELECT atq.id AS task_id, atq.status, atq.chat_session_id
FROM agent_task_queue atq
JOIN chat_session cs ON cs.id = atq.chat_session_id
WHERE cs.workspace_id = $1
AND cs.creator_id = $2
AND atq.status IN ('queued', 'dispatched', 'running')
ORDER BY atq.created_at DESC
`
type ListPendingChatTasksByCreatorParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatorID pgtype.UUID `json:"creator_id"`
}
type ListPendingChatTasksByCreatorRow struct {
TaskID pgtype.UUID `json:"task_id"`
Status string `json:"status"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
}
// Aggregate view of all in-flight chat tasks owned by a given creator in a
// workspace. Drives the FAB's "running" indicator when the chat window is
// closed and no single session's query is active.
func (q *Queries) ListPendingChatTasksByCreator(ctx context.Context, arg ListPendingChatTasksByCreatorParams) ([]ListPendingChatTasksByCreatorRow, error) {
rows, err := q.db.Query(ctx, listPendingChatTasksByCreator, arg.WorkspaceID, arg.CreatorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListPendingChatTasksByCreatorRow{}
for rows.Next() {
var i ListPendingChatTasksByCreatorRow
if err := rows.Scan(&i.TaskID, &i.Status, &i.ChatSessionID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markChatSessionRead = `-- name: MarkChatSessionRead :exec
UPDATE chat_session SET unread_since = NULL
WHERE id = $1
`
// Clears unread_since, dropping the session's unread count to 0.
func (q *Queries) MarkChatSessionRead(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markChatSessionRead, id)
return err
}
const setUnreadSinceIfNull = `-- name: SetUnreadSinceIfNull :exec
UPDATE chat_session SET unread_since = now()
WHERE id = $1 AND unread_since IS NULL
`
// Atomically stamps the first unread assistant message's arrival time.
// No-op if the session is already in "has unread" state — keeps the earliest
// unread boundary stable across multiple incoming replies.
func (q *Queries) SetUnreadSinceIfNull(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, setUnreadSinceIfNull, id)
return err
}
const touchChatSession = `-- name: TouchChatSession :exec
UPDATE chat_session SET updated_at = now()
WHERE id = $1
@@ -367,7 +500,7 @@ func (q *Queries) UpdateChatSessionSession(ctx context.Context, arg UpdateChatSe
const updateChatSessionTitle = `-- name: UpdateChatSessionTitle :one
UPDATE chat_session SET title = $2, updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at
RETURNING id, workspace_id, agent_id, creator_id, title, session_id, work_dir, status, created_at, updated_at, unread_since
`
type UpdateChatSessionTitleParams struct {
@@ -389,6 +522,7 @@ func (q *Queries) UpdateChatSessionTitle(ctx context.Context, arg UpdateChatSess
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.UnreadSince,
)
return i, err
}

View File

@@ -153,6 +153,26 @@ func (q *Queries) HasAgentCommentedSince(ctx context.Context, arg HasAgentCommen
return commented, err
}
const hasAgentRepliedInThread = `-- name: HasAgentRepliedInThread :one
SELECT count(*) > 0 AS has_replied FROM comment
WHERE parent_id = $1 AND author_type = 'agent' AND author_id = $2
`
type HasAgentRepliedInThreadParams struct {
ParentID pgtype.UUID `json:"parent_id"`
AgentID pgtype.UUID `json:"agent_id"`
}
// Returns true if the given agent has posted a reply in the thread rooted at
// the specified parent comment. Used to detect agent participation in a
// member-started thread so that follow-up member replies still trigger the agent.
func (q *Queries) HasAgentRepliedInThread(ctx context.Context, arg HasAgentRepliedInThreadParams) (bool, error) {
row := q.db.QueryRow(ctx, hasAgentRepliedInThread, arg.ParentID, arg.AgentID)
var has_replied bool
err := row.Scan(&has_replied)
return has_replied, err
}
const listComments = `-- name: ListComments :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2

View File

@@ -116,6 +116,7 @@ type ChatSession struct {
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
UnreadSince pgtype.Timestamptz `json:"unread_since"`
}
type Comment struct {

View File

@@ -12,14 +12,21 @@ SELECT * FROM chat_session
WHERE id = $1 AND workspace_id = $2;
-- name: ListChatSessionsByCreator :many
SELECT * FROM chat_session
WHERE workspace_id = $1 AND creator_id = $2 AND status = 'active'
ORDER BY updated_at DESC;
-- Returns active sessions with a boolean unread flag. Unread is strictly
-- per-session: either the user has uncleared assistant replies in this
-- session or they don't. Counting messages would be misleading.
SELECT cs.*,
(cs.unread_since IS NOT NULL)::bool AS has_unread
FROM chat_session cs
WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
ORDER BY cs.updated_at DESC;
-- name: ListAllChatSessionsByCreator :many
SELECT * FROM chat_session
WHERE workspace_id = $1 AND creator_id = $2
ORDER BY updated_at DESC;
SELECT cs.*,
(cs.unread_since IS NOT NULL)::bool AS has_unread
FROM chat_session cs
WHERE cs.workspace_id = $1 AND cs.creator_id = $2
ORDER BY cs.updated_at DESC;
-- name: UpdateChatSessionTitle :one
UPDATE chat_session SET title = $2, updated_at = now()
@@ -62,3 +69,35 @@ SELECT session_id, work_dir FROM agent_task_queue
WHERE chat_session_id = $1 AND status = 'completed' AND session_id IS NOT NULL
ORDER BY completed_at DESC
LIMIT 1;
-- name: GetPendingChatTask :one
-- Returns the most recent in-flight task for a chat session, if any.
-- Used by the frontend to recover pending state after refresh / reopen.
SELECT id, status FROM agent_task_queue
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running')
ORDER BY created_at DESC
LIMIT 1;
-- name: ListPendingChatTasksByCreator :many
-- Aggregate view of all in-flight chat tasks owned by a given creator in a
-- workspace. Drives the FAB's "running" indicator when the chat window is
-- closed and no single session's query is active.
SELECT atq.id AS task_id, atq.status, atq.chat_session_id
FROM agent_task_queue atq
JOIN chat_session cs ON cs.id = atq.chat_session_id
WHERE cs.workspace_id = $1
AND cs.creator_id = $2
AND atq.status IN ('queued', 'dispatched', 'running')
ORDER BY atq.created_at DESC;
-- name: MarkChatSessionRead :exec
-- Clears unread_since, dropping the session's unread count to 0.
UPDATE chat_session SET unread_since = NULL
WHERE id = $1;
-- name: SetUnreadSinceIfNull :exec
-- Atomically stamps the first unread assistant message's arrival time.
-- No-op if the session is already in "has unread" state — keeps the earliest
-- unread boundary stable across multiple incoming replies.
UPDATE chat_session SET unread_since = now()
WHERE id = $1 AND unread_since IS NULL;

View File

@@ -53,5 +53,12 @@ SELECT EXISTS (
AND created_at >= @since
) AS commented;
-- name: HasAgentRepliedInThread :one
-- Returns true if the given agent has posted a reply in the thread rooted at
-- the specified parent comment. Used to detect agent participation in a
-- member-started thread so that follow-up member replies still trigger the agent.
SELECT count(*) > 0 AS has_replied FROM comment
WHERE parent_id = @parent_id AND author_type = 'agent' AND author_id = @agent_id;
-- name: DeleteComment :exec
DELETE FROM comment WHERE id = $1;

View File

@@ -59,8 +59,9 @@ const (
EventSkillDeleted = "skill:deleted"
// Chat events
EventChatMessage = "chat:message"
EventChatDone = "chat:done"
EventChatMessage = "chat:message"
EventChatDone = "chat:done"
EventChatSessionRead = "chat:session_read"
// Project events
EventProjectCreated = "project:created"

View File

@@ -74,6 +74,12 @@ type ChatDonePayload struct {
Content string `json:"content"`
}
// ChatSessionReadPayload is broadcast when the creator marks a session as read.
// Fires to other devices so their unread counts stay in sync.
type ChatSessionReadPayload struct {
ChatSessionID string `json:"chat_session_id"`
}
// HeartbeatPayload is sent periodically from daemon to server.
type HeartbeatPayload struct {
DaemonID string `json:"daemon_id"`