Compare commits

..

74 Commits

Author SHA1 Message Date
Jiang Bohan
55cfaa1e1d fix(daemon/execenv): make posting result comment an explicit workflow step
Agents were silently finishing tasks without ever posting results to the
issue — their final reply stayed in terminal/log output only. See MUL-1124.

Root cause: the injected CLAUDE.md / AGENTS.md put "post a comment with
results" inside the body of step 4 (a nested clause in the default workflow
description), so skill-driven flows jumped straight from "do the work" to
`status in_review`.

- Hoist posting the result comment into its own explicit, numbered step in
  both assignment-triggered and comment-triggered workflows, with the exact
  `multica issue comment add` invocation inlined.
- Add a hard warning at the top of the Output section that terminal / chat
  text is never delivered to the user.
- Add regression test covering both workflow branches.
2026-04-20 16:51:41 +08:00
LinYushen
9fa08fb16a chore(server): log pgxpool config + periodic stats to confirm pool exhaustion (#1378)
After merging the per-phase claim slow-logs (#1376), the prod data showed
the smoking gun: unrelated endpoints (claim, heartbeat, /api/workspaces,
ping) all completed at the *same wall-clock instant* with durations
clustered at ~1.4s and ~2.88s — and within the claim breakdown,
list_pending_ms was 713ms even when list_pending_count=0.

A 0-row indexed scan can't take 713ms, and unrelated endpoints don't
synchronize their completion by accident. The only explanation that fits
is requests blocking on a shared resource and being released together.
The most likely culprit is pgxpool connection-acquire wait: pgxpool.New
is called with no config, so MaxConns defaults to max(4, NumCPU) — under
the daemon poll fan-in this is trivially exhausted.

This change adds the observability needed to confirm/refute that without
changing pool sizing yet (pool sizing is a follow-up once we have data):

- logPoolConfig: prints MaxConns / MinConns / MaxConnLifetime /
  MaxConnIdleTime / HealthCheckPeriod once at startup. Surfacing the
  effective limit is critical because the default is surprisingly small
  and easy to mistake for 'database is slow'.

- runDBStatsLogger: samples pool.Stat() every 15s (matches daemon
  heartbeat cadence for easy correlation). Emits INFO with TotalConns /
  AcquiredConns / IdleConns and per-window deltas (acquire_count,
  empty_acquire, canceled_acquire, avg_acquire_ms). Auto-upgrades to
  WARN whenever empty_acquire_delta > 0 or canceled_acquire_delta > 0
  — those are the direct symptom of a request having to wait because
  no idle connection was available.

If on prod we see 'db pool pressure' WARN lines coincident with the
claim_endpoint slow lines, the hypothesis is confirmed and the fix
becomes a one-liner (pool config tuning + the existing N+1 reduction
ideas to lower demand).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:36:49 +08:00
LinYushen
cf74327aa6 chore(server): add slow-path timing logs for /tasks/claim (#1376)
* chore(server): add slow-path timing logs for /tasks/claim

We're seeing 3s+ tail latency on POST /api/daemon/runtimes/{rid}/tasks/claim
in production. Before changing the code, add structured timing logs along
the entire claim path so we can confirm where the time is actually going.

Three layers, all gated by a slow-only threshold to avoid log spam at the
default 3s daemon poll cadence:

- handler.ClaimTaskByRuntime (>=500ms): splits auth_ms / claim_ms /
  build_ms so we can tell whether the slowness is in the actual claim
  query or the post-claim response assembly (GetAgent, LoadAgentSkills,
  GetIssue, GetWorkspace, GetComment, GetLastTaskSession, or the chat
  branch's 4 queries).

- service.ClaimTaskForRuntime (>=300ms): logs list_pending_ms,
  list_pending_count, agents_tried, claim_loop_ms — directly validates
  the suspected N+1 amplification (one ListPendingTasksByRuntime + one
  ClaimTask per unique agent).

- service.ClaimTask (>=300ms): splits get_agent_ms / count_running_ms /
  claim_agent_ms so we can isolate the NOT EXISTS + FOR UPDATE SKIP
  LOCKED cost from the surrounding metadata reads.

Pure observability change. No behavior change in the request path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(server): widen claim slow-log to cover post-claim DB work and error paths

Address review feedback on #1376: the previous version emitted
'claim_task slow' before updateAgentStatus and broadcastTaskDispatch,
both of which can hit the DB (broadcastTaskDispatch goes through
ResolveTaskWorkspaceID and may re-query issue/chat_session/autopilot_run).
That meant a claim that was actually slow in the post-claim tail would
either be under-counted or not logged at all, defeating the purpose of
the instrumentation.

Changes:
- ClaimTask: switch to defer-based exit logging. Adds update_status_ms
  and dispatch_ms phase fields. Error paths now also emit a slow log
  with outcome=error_get_agent / error_count_running / error_claim.
- ClaimTaskForRuntime: same defer pattern; error paths log with
  outcome=error_list / error_claim, partial loop time still captured.
- ClaimTaskByRuntime handler: same defer pattern; auth-failure / claim-
  error paths now also carry phase timings (outcome=unauth / error_claim).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:21:32 +08:00
Bohan Jiang
951f51408a fix(agent/comments): prevent resumed sessions from reusing stale --parent UUID (#1374)
* fix(agent/comments): re-emit trigger comment id every turn + server-side parent_id guard

Resumed Claude sessions keep prior turns' tool calls in context, so a
comment-triggered task could reuse the PREVIOUS turn's --parent UUID
instead of the current trigger's. The reply landed in the wrong thread
(MUL-1125): backend stored exactly what the agent sent, but the agent
pulled a stale UUID from its own conversation memory.

Two layers of defense:

1. Extract BuildCommentReplyInstructions so daemon.buildCommentPrompt
   and execenv.InjectRuntimeConfig emit the same "use this exact
   --parent, do not reuse values from previous turns" block. The
   per-turn prompt now carries the current TriggerCommentID, which it
   previously relied on CLAUDE.md for (and CLAUDE.md isn't re-read
   mid-session).

2. Handler-side guard in CreateComment: when an agent posts from inside
   a comment-triggered task (X-Agent-ID + X-Task-ID, task has
   TriggerCommentID), require parent_id == task.TriggerCommentID or
   return 409. Assignment-triggered tasks are untouched.

* fix(agent/comments): scope parent_id guard to the task's own issue

Two issues from CI + GPT-Boy's review:

1. Guard was too broad: the CLI stamps X-Task-ID on every request, so an
   agent legitimately commenting on a different issue while its current
   task was comment-triggered would get 409'd with the wrong issue's
   trigger comment id. Narrow the guard to fire only when the request's
   issue matches the task's own issue — cross-issue agent activity
   stays unblocked.

2. The integration test tried to insert a second queued task for the
   same (agent, issue), which hits the idx_one_pending_task_per_issue_agent
   unique index. Replace the assignment-triggered-task sub-case with a
   cross-issue regression test (the scenario we now need to cover anyway):
   post on issue B while X-Task-ID points at a comment-triggered task on
   issue A, expect 201.
2026-04-20 15:56:16 +08:00
Bohan Jiang
be78b66e4e feat(autopilot): multi-select days in weekly trigger config (#1368)
Replace the single day picker in the "Weekly" frequency with a multi-select
so users can schedule on any combination of weekdays (e.g. Mon/Wed/Fri)
in addition to the existing "Weekdays" Mon-Fri preset.

The backend already accepts any day-of-week list in the cron expression,
so this is a frontend-only change. Relabels the tab to "Days" to reflect
the new behavior.
2026-04-20 15:01:36 +08:00
Bohan Jiang
ec73710dd2 fix(agent/codex): surface stderr tail in initialize / turn startup errors (#1314)
* fix(agent/codex): surface stderr tail in initialize / turn startup errors

When codex app-server exits before the JSON-RPC handshake completes —
e.g. because the user put a flag in custom_args that the subcommand
rejects — the Result.Error users see is `codex initialize failed:
codex process exited`, while codex's actual complaint (typically
something like `error: unexpected argument '-m' found`) only lives in
daemon logs.

Wrap the stderr writer with a bounded stderrTail that still forwards
to the slog logWriter but also retains the last 2 KiB of bytes
written. Include that tail on the three startup failure paths
(initialize, startOrResumeThread, turn/start). Runtime cancellation
paths are left untouched — they're our own abort and the stderr
context isn't a clear signal there.

Refs #1308. Complement to #1310 / #1312 — lets "bad custom_args fail
loudly" actually be workable by giving the failure a real message.

* fix(agent/codex): join cmd.Wait() before sampling stderr tail

Addressing review of #1314: reading stderrBuf.Tail() right after
c.request returns "codex process exited" was racy. Nothing in that
path synchronizes with os/exec's internal stderr copy goroutine —
cmd.Wait() is the only documented join point. The original defer ran
cmd.Wait() later, but by then we had already built Result.Error from
a potentially-empty Tail().

Replace the ad-hoc deferred stdin.Close()/cmd.Wait() with a
sync.Once-wrapped drainAndWait closure. Call it explicitly on the
three startup failure paths before sampling the tail; keep it as the
cleanup defer so the success path behaves identically.

Also add TestCodexExecuteSurfacesStderrWhenChildExitsEarly: spawns a
real subprocess that prints to stderr and exits before responding to
initialize, runs it through Execute, and asserts Result.Error
contains the stderr hint. This covers the full timing path the
reviewer flagged, which the helper-level tests in this PR did not.
2026-04-20 14:38:32 +08:00
Bohan Jiang
62a7c05589 feat(desktop): hourly update poll + manual check button in settings (#1366)
* feat(desktop): hourly update poll + manual check button in settings

The previous updater only ran one check 5s after launch, so a missed
or failed initial check meant the user had to fully restart the app to
see a new release. Add a 1h background poll for long sessions and a
"Check now" button under a new Updates tab in Settings so the user can
trigger a check on demand without waiting.

The button reuses the existing autoUpdater pipeline — when an update is
available the existing corner notification still drives the download
flow; the settings tab only surfaces the immediate check result
(up-to-date / available / error).

* fix(desktop): trust electron-updater's isUpdateAvailable for the manual check

Per review: deriving `available` from a version-string compare is wrong —
`updateInfo.version` can differ from `app.getVersion()` while
electron-updater still suppresses `update-available` (pre-release channels,
staged rollouts, downgrade scenarios, min-system-version gates). In those
cases the settings tab would say "vX is available" but no corner download
prompt would ever appear. Use `result?.isUpdateAvailable` instead, which is
electron-updater's own answer.
2026-04-20 14:32:54 +08:00
devv-eve
c0be1b7ce9 fix(slugs): audit admin/multica/new/www + reserve in slug list (MUL-972) (#1359)
Follow-up to PR #1188 / migration 047, which intentionally omitted the
five historical conflict slugs (admin / multica / new / setup / www) from
the reserved-slug audit because each had one production workspace using
it at the time and we did not want to block deploy on owner outreach.

MUL-972 closed that loop on prd for four of the five:

  * admin   (99cd10e4-…) → renamed to legacy-admin-99cd10e4
  * multica (dcd796aa-…) → renamed to legacy-multica-dcd796aa
  * new     (e391e3ed-…) → renamed to legacy-new-e391e3ed
  * www     (5e8d38b2-…) → workspace deleted (was empty: 0 issues /
                           projects / agents, owner-only member; 18
                           workspace-FK relations all CASCADE)

This PR:

1. Adds migration 049_audit_legacy_reserved_slugs which audits those
   four slugs against workspace.slug at startup. If any future workspace
   slips in with one of them, startup fails loudly via RAISE EXCEPTION
   instead of being silently shadowed by a global route. Mirrors the
   structure of 047.

2. Adds 'multica' / 'www' / 'new' to the reserved-slug allow-deny list
   in both the Go handler and the shared TS list (admin was already in
   both). Keeps the two lists in lockstep per the convention enforced
   in workspace_reserved_slugs.go header.

setup is STILL exempt from the audit and is intentionally NOT added to
the reserved list. The setup workspace (b43f0bc2-…) is a real production
user (owner: Roberto Betancourth, building a chants/Alabanzas app) and
is being handled out-of-band via owner outreach. A separate follow-up
migration will fold setup into the audit once that workspace's slug has
been migrated.

Migration is intentionally shipped AFTER the prd data fix (not before):
049 will RAISE EXCEPTION on any remaining conflict, so we want the data
state clean first. Rollout order:
  prd data fix (done by db-boy on 2026-04-20) → this PR.

Tested:
  - go test ./server/internal/handler/ -run TestReserved → pass
  - pnpm --filter @multica/core test consistency → pass (4/4 in
    consistency.test.ts; global-prefix↔reserved invariant holds)

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-19 23:21:31 -07:00
Bohan Jiang
4ce3e5ddf4 fix(auth): hand off session to Desktop when web is already logged in (#1364)
When Desktop opens /login?platform=desktop in the browser and the user
already has a valid web session, the page previously bounced them to
their workspace and Desktop never received a token. Now we mint a bearer
token via issueCliToken and redirect through the multica:// deep link so
Desktop completes sign-in without a second Google round-trip.

Refs: MUL-1080
2026-04-20 14:12:32 +08:00
Bohan Jiang
bd445782d5 fix(openclaw): stop passing unsupported flags and actually deliver AgentInstructions (#1362)
Fixes #1332.

Two regressions introduced in #910 (2026-04-14, "OpenClaw backend P0+P1
improvements") that together block all openclaw users:

1. `openclaw agent` does not accept `--model` or `--system-prompt`, so
   any agent configured with a Model field crashed in ~700ms with
   `exit status 1`. Remove both forwards, and add them to
   openclawBlockedArgs so custom_args can't reintroduce the crash.
   Model is bound at registration time via `openclaw agents
   add/update --model`.

2. AgentInstructions were written to `{workDir}/AGENTS.md` by
   execenv.InjectRuntimeConfig, but openclaw loads bootstrap files
   from its own workspace dir — the file was never read, so every
   agent's Instructions field was silently discarded. Populate
   opts.SystemPrompt for the openclaw provider in runTask and
   prepend it to the `--message` payload in the backend so the
   model actually receives the instructions.

Other providers surface instructions through their native runtime
config file (CLAUDE.md / AGENTS.md / GEMINI.md) and are intentionally
left unchanged to avoid double injection.

Extract buildOpenclawArgs so arg construction is directly testable;
add unit tests covering the removed flags, the SystemPrompt prepend,
and custom_args filtering.
2026-04-20 14:01:41 +08:00
devv-eve
5fa1da448f fix(chat): preserve chat session resume pointer across failures (#1360)
* fix(chat): preserve chat session resume pointer across failures

The chat 'forgets earlier messages' bug came from PriorSessionID being
silently lost in several edge cases:

- UpdateChatSessionSession unconditionally overwrote chat_session.session_id,
  so any task that completed without a session_id (early agent crash,
  missing result) wiped the resume pointer to NULL.
- CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A
  follow-up chat message claimed in between resumed against a stale (or
  NULL) session and started over.
- FailAgentTask never wrote session_id back, so a task that established
  a real session before failing lost its resume pointer.
- ClaimTaskByRuntime only trusted chat_session.session_id and never
  fell back to the existing GetLastChatTaskSession query, so a single
  bad turn could permanently drop the conversation memory.

This change:

- Use COALESCE in UpdateChatSessionSession so empty inputs preserve the
  existing pointer; surface DB errors instead of swallowing them.
- Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside
  the same transaction (TaskService now takes a TxStarter).
- Extend FailAgentTask + the daemon FailTask path (client, handler,
  service) to forward session_id/work_dir, so failed/blocked tasks that
  built a real session still record it.
- Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the
  chat_session pointer is missing, and include failed tasks in that
  lookup so a single failure can't lose the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): forward session_id/work_dir on blocked + timeout paths

runTask previously dropped result.SessionID and env.WorkDir on the
non-completed return paths:

- timeout returned a naked error, so handleTask called FailTask with
  empty session info and the chat resume pointer was either left stale
  or eventually overwritten with NULL.
- blocked / failed (default branch) returned a TaskResult without
  SessionID / WorkDir, so even though FailTask now COALESCEs into
  chat_session, there was no value to write through.
- the empty-output completion path was the same: it raised an error
  even when a real session_id had been built.

All three paths now return a TaskResult that carries the SessionID /
WorkDir the backend produced. Combined with the COALESCE-based update
in UpdateChatSessionSession and the FailTask plumbing introduced in
PR #1360, the next chat turn can always resume from the latest agent
session — even when the previous turn timed out, was rate-limited, or
returned an empty completion — instead of starting over with no memory
of the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): capture session id from session.start as fallback

The Copilot backend only read sessionId from the synthetic 'result'
event, ignoring the one already present on session.start. When the CLI
was killed before result arrived (timeout, cancel, crash, or a
session.error mid-turn), the daemon reported SessionID="" and the
chat-session resume pointer could not advance — causing the chat to
silently drop conversation memory on the next turn.

Capture session.start.sessionId into state up front, and only let
'result' overwrite it when it actually carries one. result still wins
when present (it is the authoritative end-of-turn record).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): parse premiumRequests as float to preserve session id

Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5),
not an integer. Our copilotResultUsage struct typed it as int, which
made the entire 'result' line fail json.Unmarshal — silently dropping
sessionId on every turn.

This was the real cause of chat memory loss: the daemon reported
SessionID="" to the server, chat_session.session_id stayed NULL, and
the next chat turn never received --resume <id>, so each turn started
a fresh Copilot session with no prior context.

Add a regression test using the real JSON line from CLI v1.0.32 that
asserts sessionId is preserved when premiumRequests is fractional.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: yushen <ldnvnbl@gmail.com>
2026-04-19 22:50:33 -07:00
mike.xu
556c68292f fix(cli): use rundll32 instead of cmd start on Windows (#1202)
Windows 下 CLI 登录用 cmd /c start 打开浏览器,cmd.exe 会把 URL 中的 & 解释为命令分隔符,导致 OAuth 回调 URL 中 &state=... 和 &cli_state=... 参数被截断。

改用 rundll32 url.dll,FileProtocolHandler,将 URL 原样传递给操作系统 shell 处理程序,不对特殊字符做任何解释。

Authored-by: xushi-mike <xushi_1983@hotmail.com>
2026-04-20 13:43:23 +08:00
Bohan Jiang
96ee5bba52 docs(selfhost): surface APP_ENV + 888888 gating in .env.example (#1361)
The v0.2.6 self-host security fix (#1307) defaults APP_ENV to "production"
in docker-compose.selfhost.yml, which disables the 888888 master verification
code. The follow-up docs PR (#1313) covered SELF_HOSTING.md and the
installers, but `.env.example` — the file users actually copy and edit —
still makes no mention of APP_ENV, so operators who don't read the prose
docs hit the exact same "888888 stopped working after upgrade" confusion
reported in #1331.

- Add APP_ENV= to .env.example with a comment block that spells out the three
  cases (Docker default, local dev, evaluation) and warns against enabling
  the bypass on public instances. Keeping the value empty preserves the
  current `make dev` UX (Go server reads empty → treats as non-production →
  888888 works locally) while `${APP_ENV:-production}` in the compose file
  still ensures public Docker deployments are safe by default.
- Update the existing 888888 mention under # Email so it no longer
  contradicts the new gating rule.
- Update the `make selfhost` post-start banner, which still told operators
  to "Log in with any email + verification code: 888888" even after #1307
  disabled that path by default.
2026-04-20 13:26:42 +08:00
Jiayuan Zhang
2ab89d4690 feat(editor): create sub-issue from selected text in bubble menu (#1348)
Adds a "Create sub-issue from selection" button to the editor bubble
menu. When an issue context is present (description editor, comment
input, reply input, comment edit), selecting text and clicking the
button creates a new issue parented under the current issue and
replaces the selection with a mention link to the new issue.
2026-04-20 12:40:20 +08:00
Azaan Ali Raza
b428f36ca6 feat: add ALLOW_SIGNUP + ALLOWED_EMAIL_* for self-hosted instances (#1098)
Closes #930

- Added environment variables to control signups
- Updated frontend to hide signup text when disabled
- Added backend check to block new user creation via magic link
- Updated .env.example
2026-04-19 21:02:42 -07:00
Jiayuan Zhang
239ce3d40f fix(editor): blur ContentEditor on Escape (#1338)
ESC did nothing inside the issue description editor because browsers
don't blur contenteditable elements by default, leaving users stuck in
the editor with no keyboard escape hatch.

Add a blur-shortcut extension mirroring TitleEditor's behavior and wire
it into ContentEditor's edit-mode extension set.
2026-04-20 10:17:32 +08:00
Jiayuan Zhang
a7e9801c83 feat(views): show issue title in detail page header (#1344)
Previously the issue detail top bar only showed 'workspace name > identifier'.
Add the issue title next to the identifier so users can see what issue they're
viewing without scrolling.
2026-04-20 00:36:10 +08:00
Jiayuan Zhang
b8907dda8d fix(views): prevent infinite re-render loops in sidebar and chat resize (#1322)
* fix(sidebar): stabilize useQuery default arrays to prevent render loop

Inline `= []` defaults on `useQuery` return a new array reference on
every render when `data` is undefined (query disabled or mid-load).
Downstream effects/memos that depend on the value then fire every
render; the pinned-items `useEffect` compounds this by calling
`setLocalPinned` each time, so under sustained `data === undefined`
(e.g. backend unreachable, WebSocket in reconnect loop) React trips
its "Maximum update depth exceeded" guard and the sidebar becomes
unusable.

Use module-level empty-array constants so the default identity stays
stable across renders.

* fix(chat): short-circuit ResizeObserver update when bounds unchanged

The resize observer always called `setRevision(r => r + 1)` from its
callback, even when `clientWidth`/`clientHeight` were identical to the
previous reading. Any spurious notification — sub-pixel layout jitter
during mount, or an ancestor reflow triggered by an unrelated state
update — then fed back into the same render cycle and could exceed
React's update-depth limit.

Guard the state bump by comparing against the previous bounds, and
leave `setBoundsReady(true)` outside the guard since it's idempotent.
2026-04-18 23:24:32 +08:00
Bohan Jiang
6cd49e132d docs(selfhost): clarify 888888 master code is disabled by default in Docker (#1313)
Following #1307, the Docker self-host stack defaults to APP_ENV=production,
which disables the 888888 master verification code on auth.go:169. The
installer banners and self-hosting docs still told operators to log in with
888888, leaving them stuck.

Update install.sh, install.ps1, SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md,
and self-hosting.mdx to document the three login paths: configure
RESEND_API_KEY (recommended), set APP_ENV=development to enable 888888 for
private evaluation, or read the dev verification code from backend container
logs. Also warn against enabling APP_ENV=development on public instances.
2026-04-18 14:30:35 +08:00
Bohan Jiang
a6db465e46 fix(ui/agents): drop Codex-incompatible --model example from custom args tab (#1310)
* fix(agent/codex): route custom_args -m/--model to thread/start payload

Codex agents spawn via `codex app-server --listen stdio://`, which does
not accept `-m` / `--model` (those belong to the normal Codex CLI). When
a user's custom_args still carried those tokens the process exited
before the JSON-RPC initialize handshake with `codex process exited`,
with no actionable error.

Extract `-m <v>`, `--model <v>`, and `--model=<v>` from opts.CustomArgs
before invoking app-server and promote the value into opts.Model, so
that startOrResumeThread can pass it through the `thread/start` payload
where Codex actually reads the field.

Fixes #1308.

* fix(ui/agents): drop Codex-incompatible --model example from custom args tab

The helper text and placeholder suggested `--model claude-sonnet-4-…` as
a custom CLI argument, which is valid for Claude but crashes Codex
agents (its `app-server` subcommand does not accept model flags). Swap
in provider-agnostic copy so the UI no longer steers users into an
invalid configuration for non-Claude runtimes.

Refs #1308.

* revert "fix(agent/codex): route custom_args -m/--model to thread/start payload"

This reverts f18355b2. After review, extracting `-m`/`--model` out of
opts.CustomArgs and promoting them into the thread/start payload is the
wrong shape of fix: agent CLIs have many flags their non-interactive
modes don't accept, and hand-translating a subset case-by-case doesn't
scale — it pushes us toward an ever-growing list of per-backend arg
rewriters.

The preferred direction is to teach users via the UI what command their
custom_args extend (see the launch_header preview in #1312) and let
bad configurations fail loudly. If the resulting error is hard to read
that's a separate improvement we should make on the failure path, not
by silently rewriting user input.

Refs #1308.
2026-04-18 14:25:58 +08:00
Kagura
965561a6cc fix(selfhost): pass APP_ENV to backend container, default to production (#1307) 2026-04-18 14:25:23 +08:00
Bohan Jiang
163f34f918 feat(agents): show launch mode preview in custom args tab (#1312)
* feat(agent): add LaunchHeader per agent type

Each backend in server/pkg/agent/ hardcodes a stable command skeleton
(e.g. `codex app-server --listen stdio://`, `hermes acp`) before
appending opts.CustomArgs. Surfacing that skeleton lets the UI tell
users which command their custom_args are being appended to, so a
Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to
reach the CLI when the subcommand is actually `app-server`.

Expose only the minimum that aids judgment — binary + subcommand, or a
short mode label when there is no subcommand — and deliberately omit
transport values, internal flags, and env to keep the surface small
and renaming-safe.

Refs #1308.

* feat(handler/runtime): surface launch_header on runtime response

runtimeToResponse now derives launch_header from agent.LaunchHeader,
piggybacking on the runtime's existing provider field so the
frontend's RuntimeDevice gains the skeleton without a new endpoint or
DB query. Client gets the header for free whenever it lists agents'
runtimes — which the custom-args tab already does.

Refs #1308.

* feat(ui/agents): show launch mode preview in custom args tab

Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab
and render its launch_header as a one-line preview above the args
list, so users see `codex app-server <your args>` (or equivalent per
provider) and can tell whether a CLI-style flag like `--model` will
actually reach the invoked subcommand. Source of truth stays in the
Go backend; the TS type just carries the string.

Refs #1308.
2026-04-18 14:18:42 +08:00
Bohan Jiang
2317533da4 fix(auth): validate next= redirect target to prevent open redirect (#1309)
* refactor(auth): add sanitizeNextUrl helper in @multica/core/auth

Extracts a reusable helper that returns a post-login redirect URL only
when it's a safe single-slash relative path, and null otherwise. Rejects
absolute URLs, protocol-relative URLs, backslashes, and control
characters so call sites can safely pass the result to router.push().

Keeping the rule in a single helper (with direct unit tests) avoids
each consumer re-implementing the validation and drifting.

* fix(auth): validate next= redirect target to prevent open redirect

Closes #1116

Next.js router.push accepts absolute URLs, so a crafted
`/login?next=https://evil.example` would send the user off-origin
after a successful login. The Google OAuth callback has the same
vector via the `state=next:<url>` payload.

Sanitize both entry points through `sanitizeNextUrl` from
`@multica/core/auth` so only safe single-slash relative paths survive;
null results fall through to the existing workspace-list-based default
without any hard-coded path.

---------

Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com>
2026-04-18 13:24:01 +08:00
niceSprite
d81e6a14a6 fix(comment): assignee on_comment path should use reply id, not thread root (#1302)
* fix(comment): assignee on_comment path should use reply id, not thread root

Symmetric fix to #871 — that PR fixed the @mention path but missed the
assignee on_comment path in the same file. Replies on agent-assigned
issues were still getting trigger_comment_id = parent_id, so the daemon
fed the parent comment's content to the resumed claude session, which
then either exited with 'Already replied to comment <parent>' or silently
misrouted its answer depending on model / session state.

Reply placement (flat-thread grouping) is already decoupled from
trigger_comment_id by TaskService.createAgentComment's parent
normalization (added alongside #871), so passing comment.ID directly is
safe and matches the mention path's post-#871 behavior.

Fixes #1301

Made-with: Cursor

* test(comment): assert assignee on_comment records reply id as trigger_comment_id

Integration regression guard for #1301. Asserts that after a member posts
a reply under an agent-authored thread, the enqueued agent task's
trigger_comment_id matches the new reply, not the thread root. Without
the companion fix in comment.go the old parent-override would store the
root id and the daemon would feed stale content (via prompt.go
BuildPrompt) to the agent.

Made-with: Cursor

---------

Co-authored-by: fuxiao <fuxiao@zyql.com>
2026-04-18 13:20:11 +08:00
Bohan Jiang
e198a67f8f docs(prompt): warn agents that mention syntax is an action, not a text reference (#1306)
Agent mentions enqueue a new task; member mentions send a notification.
Without this warning, agents have used `[@Name](mention://agent/<id>)` in
prose (e.g. "GPT-Boy is correct") and accidentally re-triggered the agent.

Adds a caveat under `## Mentions` in the prompt injected into agent
runtimes, plus tightens the Agent bullet to make the side-effect explicit.
2026-04-18 13:09:07 +08:00
Bohan Jiang
0ed16fc1b1 fix(autopilots): spin the Loader2 icon while a run is in progress (#1305)
The autopilot detail page mapped `status: "running"` to a `Loader2` icon
but rendered it without `animate-spin`, so a manually-triggered run sat
on a static circle until the row flipped to completed/failed and the
user got no visual feedback that anything was happening.

Add an optional `spin: true` flag to the run-status config and apply
`animate-spin` when set. Only the running entry is marked.
2026-04-18 13:05:52 +08:00
niceSprite
746f33a38b fix(claude): clear fresh session_id on resume failure so daemon fallback fires (#1285)
When --resume targets a dead session, claude prints
"No conversation found with session ID: ..." to stderr, emits a stream-json
system init with a fresh session_id, then exits with code 1. The backend
was treating that fresh id as the authoritative session, so
daemon.go's retry-with-fresh-session fallback (SessionID == "" guard)
never triggered. Every subsequent task for the same (issue, agent) pair
stayed permanently broken until the server-side session_id was cleared by
hand.

Fix: when --resume was requested but the emitted session_id differs AND
the run failed, drop the fresh id from Result so the daemon's existing
fallback can do its job. Factored into a pure helper and unit-tested.

Fixes #1284

Co-authored-by: fuxiao <fuxiao@zyql.com>
2026-04-18 12:59:30 +08:00
Kagura
aa9305f7e4 fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1294)
* fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1276)

* test: add regression test for #1276 — ClaimTaskByRuntime autopilot workspace_id
2026-04-18 12:47:29 +08:00
Korkyzer
63800f05ff fix(agent): add per-agent mcp_config field to restore MCP access (#1168)
* fix(agent): add per-agent mcp_config field to restore MCP access

Closes #1111

The --strict-mcp-config flag was added defensively in #592 to prevent
Claude agents from inheriting MCP state from the outer Claude Code session.
It was meant to be paired with --mcp-config <path> to inject a controlled
set of MCPs, but that path was never implemented, which silently stripped
all user-scope MCPs from spawned agents.

This PR completes the original design by:

- Adding a nullable mcp_config jsonb column to the agents table
- Wiring mcp_config through AgentResponse, Create/Update requests
- Piping it into ExecOptions.McpConfig in the daemon
- Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs
- Blocklisting --mcp-config in claudeBlockedArgs to prevent override
  via custom_args

Does not touch Codex provider (tracked separately in #674).
Does not implement Multica MCP auto-injection (out of scope).

* fix: disambiguate JSON null vs absent for mcp_config
2026-04-18 01:35:22 +08:00
LinYushen
133a1f1c16 ci(release): restrict tag pattern to semver and reject -dirty tags (#1280)
The release workflow previously triggered on 'v*', which matched a
stray 'v0.2.5-dirty' tag pushed to the repository. GoReleaser ran
again and overwrote the Homebrew formula with a 0.2.5-dirty version
whose tarball URLs 404.

Tighten the trigger to semver-shaped tags and add an explicit guard
that fails the job if the tag name contains '-dirty' (which can come
from 'git describe --tags --dirty').

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 07:34:25 -07:00
Bohan Jiang
b1b66ab05d ci: exclude apps/docs from frontend build/typecheck/test (#1279)
Docs site is no longer auto-deployed via Vercel (disabled in
dashboard), so building it on every PR adds friction without
catching anything actionable. Use turbo's negative filter to
skip @multica/docs across all three tasks.
2026-04-17 22:00:23 +08:00
niceSprite
0fc9641bf6 fix(docker): add restart: unless-stopped to self-host compose (#1274)
Self-hosted services (postgres, backend, frontend) should restart
automatically on failure or host reboot. This is standard practice
for production docker-compose deployments.

Co-authored-by: Zhazha <zhazha@openclaw.internal>
2026-04-17 21:57:55 +08:00
Naiyuan Qing
4223d32b37 fix(sidebar): prevent pin drag from reloading page and smooth drop animation (#1271)
- Mark AppLink draggable={false} and add pointer-events-none while
  dragging, so the browser's native <a> drag (which otherwise navigates
  to the pin's href on mouse release) is suppressed.
- Introduce a component-local pinnedItems snapshot gated by an
  isDraggingRef, so a mid-drag TQ cache write (optimistic or WS
  refetch) cannot reorder the DOM under dnd-kit's drop animation.
  Mirrors the pattern already used by board-view.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:11:39 +08:00
devv-eve
b2307a5ee9 fix(execenv): write Copilot skills to .github/skills/ for native discovery (#1270)
GitHub Copilot CLI scans project-level skills from .github/skills/<name>/SKILL.md
(per the official cli-config-dir-reference docs), not from .agent_context/skills/.
Previously, skills injected for the copilot provider were placed under
.agent_context/skills/ and only referenced by name in AGENTS.md, meaning
Copilot would not actually pick them up.

- resolveSkillsDir: add a dedicated copilot case writing to .github/skills/
- Update doc comments in context.go and runtime_config.go
- Add TestWriteContextFilesCopilotNativeSkills covering the new path and
  ensuring .agent_context/skills/ is not created for copilot

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 03:07:32 -07:00
Bohan Jiang
c85c43ed0e docs: add v0.2.5 changelog entry (2026-04-17) (#1269) 2026-04-17 17:54:11 +08:00
devv-eve
eecb3a2bc8 fix(desktop): use releaseType instead of publishingType in electron-builder publish config (#1268)
electron-builder 26.8.1 rejects publishingType under the GitHub publisher;
the correct option for selecting draft/prerelease/release is releaseType.
Using publishingType caused schema validation to fail during packaging.

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:47:57 +08:00
Black
2c1478a69c fix(agents): make issue tasks easier to open from agent details (#1152)
* fix(agents): make issue tasks easier to open from agent details

Make task rows in the Tasks tab navigate directly to the related issue
detail page when issue data is available, using AppLink for cross-platform
compatibility. Rows without resolved issue data remain non-clickable.
Adds a subtle hover shadow to make the interactive area more discoverable.

Closes #1129

* fix(agents): use workspace issue paths in tasks tab

* test(agents): cover tasks tab issue links
2026-04-17 17:40:27 +08:00
Bohan Jiang
bf31fa4b39 fix(web): move /docs rewrite to beforeFiles (#1266)
* feat(docs): mount docs site at /docs subpath via basePath + multi-zone

Configure the Fumadocs site so it can be served at multica.ai/docs:

- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
  the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
  segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
  basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
  drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling

Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:

- Add DOCS_URL env (default http://localhost:4000) and rewrites for
  /docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv

* fix(web): move /docs rewrite to beforeFiles so [workspaceSlug] doesn't shadow it

The /docs rewrite was running in the default afterFiles slot, which is
evaluated *after* file-system routing. apps/web/app/[workspaceSlug]/
matched /docs first as a workspace named "docs" (which doesn't exist) and
returned 404 before the rewrite to the docs Vercel project ever fired.

Splitting rewrites into beforeFiles/afterFiles puts /docs and
/docs/:path* ahead of route resolution so they always proxy to the docs
zone.
2026-04-17 16:28:31 +08:00
Bohan Jiang
9b45e0d4a6 feat(cli): add issue subscriber commands (#1265)
* feat(cli): add `issue subscriber` commands

Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.

* fix(issues): default subscribe target to resolveActor, not X-User-ID

When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.

Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
2026-04-17 16:26:00 +08:00
Bohan Jiang
7c6158f3c9 feat(docs): mount docs site at /docs subpath via basePath + multi-zone (#1160)
Configure the Fumadocs site so it can be served at multica.ai/docs:

- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
  the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
  segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
  basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
  drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling

Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:

- Add DOCS_URL env (default http://localhost:4000) and rewrites for
  /docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv
2026-04-17 16:05:30 +08:00
Bohan Jiang
4bd8533269 fix(daemon): machine-scoped daemon.id so CLI + desktop share one identity (#1263)
Before this PR, `EnsureDaemonID(profile)` wrote to ~/.multica/profiles/
<profile>/daemon.id — meaning the same physical machine minted a different
UUID per profile. On any host running both the CLI-spawned daemon (default
profile) and the desktop-spawned daemon (profile derived from API host),
that produced two runtime rows per provider per workspace. The server-side
`legacy_daemon_ids` merge only covers hostname variants, not UUIDs, so the
rows just piled up.

Profile boundaries are about which backend/account the daemon is talking
to, not about the physical machine. Identity should be per-machine, token
should be per-profile.

Changes:

- `EnsureDaemonID` now always reads/writes ~/.multica/daemon.id regardless
  of the `profile` argument. The argument is retained for migration-only
  use (see promotion below).
- Migration path: when the canonical file is missing and the requested
  profile has a pre-change per-profile daemon.id, promote that UUID in
  place so a user who only ever ran under a named profile keeps the same
  identity instead of minting a fresh UUID and round-tripping a merge.
- New `LegacyDaemonUUIDs()` scans ~/.multica/profiles/*/daemon.id and
  returns every UUID that survives parsing. `config.go` now appends those
  to the daemon's `legacy_daemon_ids` payload, so any runtime rows
  previously registered under a per-profile UUID (on any backend) get
  merged into the canonical machine UUID at register time.

Tests replace the `ProfileIsolated` assertion with `SharedAcrossProfiles`
and add coverage for promotion, UUID scanning (including skipping corrupt
files), and the empty-profiles-dir fast path.
2026-04-17 15:29:30 +08:00
Jiayuan Zhang
488aed6abf feat(issues): show project and sub-issue progress as optional card properties (MUL-996) (#1258)
Adds two new toggleable card properties that surface issue context at a glance:
- Project: shows the parent project icon + title when the issue belongs to one.
- Sub-issue progress: gates the existing progress ring behind a card property
  so users can hide it when not useful.

Both default to on; toggled via the existing "Display" popover.
2026-04-17 15:25:45 +08:00
Bohan Jiang
a73336dcf8 feat(daemon): persistent UUID identity + legacy-id merge at register-time (#1220)
* feat(daemon): persistent UUID identity + legacy-id merge at register-time

daemon_id is now a stable UUID persisted to `<profile-dir>/daemon.id` on
first start, replacing the hostname-derived id that drifted whenever
`.local` appeared/disappeared, a system was renamed, or a profile
switched — each of which used to mint a fresh `agent_runtime` row and
strand agents on the old one.

To migrate existing installs without operator intervention, the daemon
reports every legacy id it may have registered under previously
(`host`, `host` with `.local` stripped, and `host[-profile]` variants
for both). At register-time the server looks up each candidate row
scoped to (workspace, provider), re-points its agents and tasks onto
the new UUID-keyed row, records which legacy id was subsumed in the
new `legacy_daemon_id` column for audit, and deletes the stale row.
Result: users running `xxx.local`-keyed runtimes today transparently
land on the new UUID row on next daemon restart.

The hostname-prefix `MigrateAgentsToRuntime` / `daemon_id LIKE '...-%'`
compatibility shim is no longer needed and has been removed along with
the handler call that invoked it.

* fix(daemon): handle bidirectional .local drift and case drift in legacy merge

Review on #1220 flagged two gaps in the legacy-id migration candidate set:

1. Reverse .local: LegacyDaemonIDs only added the stripped variant when the
   current hostname ended in `.local`. The opposite direction — DB has
   `foo.local`, current host is `foo` — was missed, so runtimes registered
   under the `.local` variant stayed orphaned after upgrade. Now both
   variants (`foo` and `foo.local`) are always emitted, regardless of what
   `os.Hostname()` currently returns, plus their `-<profile>` suffix forms.

2. Case drift: os.Hostname() has been observed returning different casings
   on the same machine across mDNS/reboot state. A case-sensitive `=`
   comparison stranded rows like `Jiayuans-MacBook-Pro.local` when the
   daemon later reported `jiayuans-macbook-pro.local`. FindLegacyRuntimeByDaemonID
   now uses `LOWER(daemon_id) = LOWER(@daemon_id)` on both sides, so casing
   differences merge rather than orphan. The (workspace_id, provider) prefix
   still bounds the scan to a tiny set of rows so the non-indexed LOWER()
   comparison has negligible cost.

Tests: TestLegacyDaemonIDs gets the mixed-case + reverse-direction cases;
daemon_test.go adds TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal
and TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift.

* fix(daemon): consolidate every case-duplicate legacy runtime, not just the first

Follow-up review on #1220: after switching to `LOWER(daemon_id) =
LOWER(@daemon_id)`, the single-row lookup still only merged one legacy
row per candidate. If a machine already had two rows in the DB that
differed only in casing (e.g. `Jiayuans-MacBook-Pro.local` AND
`jiayuans-macbook-pro.local` coexisting because earlier hostname drift
already minted a duplicate), only one of them got consolidated and the
other stayed orphaned — violating the "no duplicate runtime per machine
after backfill" acceptance.

- FindLegacyRuntimeByDaemonID → FindLegacyRuntimesByDaemonID (:many)
- mergeLegacyRuntimes iterates every returned row and dedupes across
  overlapping legacy candidates so `foo` and `foo.local` both resolving
  to the same stored row don't double-process

Test: TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes seeds two
case-duplicate rows with one agent each and confirms both rows are
deleted and both agents end up on the new UUID-keyed row.
2026-04-17 15:10:38 +08:00
Jiayuan Zhang
ce610a6414 refactor(cli): drop webhook/api from autopilot trigger-add (#1261)
These trigger kinds exist in the DB schema but nothing on the server
fires them:

- autopilot_scheduler.ClaimDueScheduleTriggers filters kind='schedule'
  (pkg/db/queries/autopilot.sql:150)
- DispatchAutopilot is reached only from the scheduler (source:schedule)
  or POST /api/autopilots/{id}/trigger (source:manual); no inbound
  webhook or api endpoint exists
- The UI only surfaces schedule creation

Exposing them in the CLI lets users create triggers that sit in the DB
doing nothing. Drop --kind from trigger-add, require --cron, always
send kind=schedule. Re-add the flag when the server grows a dispatch
path for the other kinds.
2026-04-17 15:07:07 +08:00
Bohan Jiang
5a6a44a69e refactor(daemon): consolidate task workspace resolver + regression test (#1259)
Follow-up to #1249. Two small follow-ups requested in review:

1. `resolveTaskWorkspaceID` was duplicated between `handler/daemon.go` and
   `service/task.go`. #1249 fixed the handler copy but left both in place,
   meaning any future branch (e.g. a fourth task link type) still needs
   to be added in two files. Promote the service method to the exported
   `TaskService.ResolveTaskWorkspaceID` and delete the handler copy.
   Handler's `requireDaemonTaskAccess` and `ListTaskMessagesByUser` now
   call through `h.TaskService`.

2. Add a regression test `TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace`
   covering the exact scenario from #1224: a task linked only via
   `AutopilotRunID` must resolve to the autopilot's workspace. The test
   asserts 404 for a cross-workspace daemon token and 200 (with status
   transitioning to `running`) for the correct-workspace token.
2026-04-17 14:50:05 +08:00
Bohan Jiang
423ceaf8f4 test(agent): regression tests for codex subagent threadId filter (#1257)
Follow-up to #1192. Document the v2 protocol contract that the
dispatch-level threadId guard relies on, and lock down the two leakage
paths the guard closes:

- turn/completed from a subagent thread must not call onTurnDone
- item/completed (agentMessage, final_answer) from a subagent thread
  must neither leak text into the output builder nor terminate the turn

Without these tests a future refactor that drops or relocates the guard
would not be caught by CI, since existing notification tests omit the
top-level threadId field and pass through unfiltered.
2026-04-17 14:49:38 +08:00
Jiayuan Zhang
9e15b17c92 feat(cli): add autopilot commands (#1234)
* feat(cli): add autopilot commands

Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).

Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.

- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md

* fix(autopilot): address code review — restrict run_only, validate workspace on update

Code review caught two issues with the initial CLI PR:

1. run_only mode is broken end-to-end. The daemon-side
   resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
   workspace from issue/chat, so run_only tasks (which have neither)
   return 404 from /start. BuildPrompt() would also emit an empty issue
   ID. The service-level resolver in internal/service/task.go already
   handles AutopilotRunID, but the daemon endpoint uses the handler
   copy. Fixing that path is out of scope for the CLI PR; drop
   run_only from the CLI and docs so we don't recommend a mode that
   cannot complete. Server continues to accept it for the existing UI.

2. UpdateAutopilot did not verify that a new assignee_id belongs to
   the workspace, unlike CreateAutopilot. This let a PATCH swap in an
   agent from a different workspace. Mirror the same
   GetAgentInWorkspace check.
2026-04-17 14:46:34 +08:00
Naiyuan Qing
e9131dfe2b fix(web): remove dashboard loading.tsx to eliminate double skeleton flash (#1256)
The route-level loading.tsx creates a Suspense boundary that shows a
generic skeleton on every page navigation within the dashboard. Since
every page already handles its own data-loading skeleton via TanStack
Query isLoading, this causes two sequential skeleton flashes:
loading.tsx skeleton → page skeleton → content.

Removing it makes the old page stay visible during route transitions
(typically <100ms), then the new page renders directly with its own
skeleton — a single, smooth transition.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:46:29 +08:00
niceSprite
462ff88df5 fix(codex): dispatch-level threadId filter for subagent notifications (#1192)
* fix(daemon): filter thread/status/changed by threadId to prevent subagent interference

When Codex CLI has memories enabled, the app-server spawns a memory
consolidation subagent as a separate thread within the same stdio
connection. When that subagent thread finishes and transitions to idle,
the daemon's codex backend mistakenly interprets the idle signal as the
main turn completing, causing it to close stdin and cancel the context
before the real turn produces any output.

Add a threadId check to the thread/status/changed handler so only
status changes from the tracked thread trigger turn completion. Signals
from subagent threads (threadId != c.threadID) are now ignored.

Fixes #1181

* fix(codex): dispatch-level threadId filter for subagent notifications

Codex multiplexes subagent threads (e.g. memory consolidation) on
the same stdio pipe. Previously only thread/status/changed had a
threadId guard, but item/completed (agentMessage + final_answer),
turn/completed, and turn/started from subagent threads could still
trigger onTurnDone or contaminate output.

Move the threadId check to the top of handleRawNotification so all
notification handlers are protected. Remove the now-redundant
per-handler check on thread/status/changed.

Fixes multica-ai/multica#1181

---------

Co-authored-by: fuxiao <fuxiao@zyql.com>
2026-04-17 14:45:09 +08:00
Kagura
ea02a394dc fix(daemon): resolve workspace ID for autopilot run_only tasks (#1224) (#1249)
resolveTaskWorkspaceID only handled tasks linked via IssueID or
ChatSessionID. Tasks created by run_only autopilots (introduced in
#1028) have only AutopilotRunID set, so the resolver returned an empty
workspace ID, causing requireDaemonTaskAccess to respond with 404.

Add an AutopilotRunID branch that looks up the autopilot run, then
its parent autopilot, to obtain the workspace ID.
2026-04-17 14:42:49 +08:00
devv-eve
fe01d58064 docs(cli): document project commands and --project flag for issues (#1253)
The project CRUD commands (list, get, create, update, delete, status)
and the `--project` flag on issue commands have been implemented in
the CLI but were not yet documented. Add them to both the docs site
reference and the repo-level CLI_AND_DAEMON.md so the feature is
discoverable.

Closes MUL-867

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 23:39:48 -07:00
Bohan Jiang
fc1938fe7d refactor(desktop): centralize shell.openExternal through a single wrapper (#1255)
Make the http/https scheme allowlist structurally enforced instead of a
convention. Move the allowlist check + shell.openExternal call into a
single openExternalSafely wrapper in external-url.ts, have both main-process
call sites (the IPC handler and setWindowOpenHandler) go through it, and
add an ESLint no-restricted-syntax rule that bans direct shell.openExternal
usage anywhere under apps/desktop/src/main/ except external-url.ts itself.

This is the follow-up to #1124: same safety guarantee, but a reviewer can
no longer accidentally reintroduce a bare shell.openExternal somewhere that
bypasses the check — the lint rule catches it at CI time. Also restores
the scheme info in the warn log (lost when the helper was extracted).

Test coverage extended to the cases the original PR review flagged but
didn't ship: casing (FILE:// / HTTPS://), javascript: / data:, ftp / smb,
vscode:// / ms-msdt:, mailto / tel, credentials-in-URL, empty / malformed.
Added two openExternalSafely tests (electron mocked) confirming allowed
URLs forward and rejected URLs do not.

Closes a follow-up bullet from the internal #1115 / #1124 review.
2026-04-17 14:36:57 +08:00
Junghwan
1ea6e6a078 fix(desktop): restrict shell.openExternal to http/https schemes (#1124)
* fix(desktop): restrict shell.openExternal to http/https schemes

The Electron main-process IPC handler for shell:openExternal called
shell.openExternal with whatever string the renderer passed, with no
scheme validation. Under this app's intentional webSecurity: false and
sandbox: false configuration (#648), any unsafe content path in the
renderer reaching this IPC becomes a way to dispatch arbitrary OS
protocol handlers — file://, smb://, vscode://, Windows ms-msdt:,
and so on.

Parse the URL and reject anything outside http/https (the only schemes
any legitimate call site uses today). Matches the Electron security
checklist guidance for openExternal on non-isolated renderers.

Closes #1115

* Close the desktop external-open gap on target=_blank links

The original fix validated only the IPC path, but the renderer could still trigger shell.openExternal through setWindowOpenHandler for target="_blank" links and window.open(). This change reuses one allowlist helper for both sinks and adds a focused unit test for the helper contract.

Constraint: Desktop shell.openExternal must stay limited to http/https despite webSecurity=false and sandbox=false
Rejected: Duplicate URL validation logic in each sink | easy to drift and harder to test
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep all desktop external-open paths on the same validator so new sinks do not bypass the allowlist
Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop test -- src/main/external-url.test.ts
Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop typecheck
Not-tested: Full desktop app manual smoke run
Related: #1115

---------

Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
2026-04-17 14:27:36 +08:00
Naiyuan Qing
c15212c0e4 fix(views): align skeleton loading states with actual page layouts (#1251)
- Issues/MyIssues: remove incorrect border-b from toolbar skeleton, add
  viewMode-aware skeleton (list vs board)
- Issue Detail: fix content padding (max-w-4xl mx-auto) and sidebar
  width (w-80), remove independent reactions/subscribers/timeline
  skeleton flashes — components now render with empty defaults
- Agents/Skills: gate skeleton on data query isLoading instead of auth
  store isLoading so skeleton covers actual data fetch
- Projects/Autopilots: add sticky column header skeleton row
- Autopilot Detail: add PageHeader skeleton, flesh out section structure
- Invite: replace plain text with Card-shaped skeleton
- Chat: migrate ChatMessageSkeleton to use Skeleton component
- Workspace layout: show MulticaIcon loading indicator instead of blank

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 14:21:10 +08:00
LinYushen
b5de04da59 fix(daemon): platform-aware Codex sandbox config to unbreak macOS network (MUL-963) (#1246)
* fix(daemon): platform-aware Codex sandbox config to unbreak macOS network

On macOS, Codex's Seatbelt sandbox in workspace-write mode silently
ignores '[sandbox_workspace_write] network_access = true' (see
openai/codex#10390). That blocks DNS inside the sandbox, so 'multica
issue get' and other CLI calls fail with 'dial tcp: lookup ...: no such
host' — this is what caused MUL-963.

Changes:

- New server/internal/daemon/execenv/codex_sandbox.go: picks a sandbox
  policy based on runtime.GOOS and the detected Codex CLI version.
  Non-darwin or darwin with a known-fixed version keeps workspace-write
  + network_access=true; older darwin falls back to danger-full-access
  and logs a warn with upgrade hint. The fix-version threshold is a
  single constant (CodexDarwinNetworkAccessFixedVersion) so it's easy
  to bump once upstream ships.
- Per-task config.toml now gets a 'multica-managed' marker block
  (BEGIN/END comments) rewritten idempotently; user-owned keys outside
  the markers are preserved. Legacy inline sandbox directives from
  earlier daemon versions are stripped on migration.
- execenv.PrepareParams gains CodexVersion; execenv.Reuse takes a
  codexVersion arg; daemon.go caches detected versions at registration
  and threads them through to Prepare/Reuse.
- Replaces the old ensureCodexNetworkAccess tests with
  platform-parameterised coverage (linux vs darwin, idempotency,
  legacy-migration, policy matrix).
- docs/codex-sandbox-troubleshooting.md: symptom fingerprint table,
  decision matrix, self-check commands, trade-offs.

Refs: MUL-963

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): hoist managed sandbox block above user tables (MUL-963)

Review on #1246 flagged that upsertMulticaManagedBlock appended the
managed block to EOF. If the user's config.toml ends inside a TOML table
(e.g. [permissions.multica] or [profiles.foo]), a trailing bare
sandbox_mode = "..." is parsed as a key of that preceding table, so
Codex silently ignores the policy the daemon meant to apply.

Two changes make the block position-independent:

- renderMulticaManagedBlock now emits only top-level key=value lines and
  uses TOML dotted-key form (sandbox_workspace_write.network_access =
  true) instead of opening a [sandbox_workspace_write] header. The block
  therefore neither inherits from nor leaks into any surrounding table.
- upsertMulticaManagedBlock always hoists the block to the top of the
  file (stripping any previously written managed block first), so the
  sandbox_mode line is always at the TOML root regardless of what the
  user put below it. This also migrates configs written by the original
  PR #1246 logic where the block was trapped behind a user table.

Added tests for the regression scenario (pre-existing [permissions.*]
table) and the legacy-trailing-block migration; updated the existing
Linux default test and the troubleshooting runbook to reflect the
dotted-key form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 14:03:13 +08:00
Bohan Jiang
131fee36d7 fix(autopilot): use readable UTC timestamp in issue description (#1250)
Autopilot was formatting the triggered-at timestamp with time.RFC3339
(e.g. "2026-04-16T14:54:32Z"), which is hard to read and confusing for
users in non-UTC timezones because the "Z" suffix looks like an error
instead of a timezone indicator.

Switch to a human-readable format ("2026-04-16 14:54 UTC") so only the
hour differs from local time; minutes match across timezones, making
the value easy to reconcile at a glance.

Fixes multica-ai/multica#1197.
2026-04-17 14:02:16 +08:00
Naiyuan Qing
c157f74a4d fix(inbox): redirect to issue page when notification not in inbox (#1248)
Shared inbox links (?issue=<id>) pointed to notifications that may no
longer exist in the current user's inbox (archived, or received by
someone else). The detail pane would fall back to an empty state and
leave the user stuck.

After inbox loads, if the selected key has no matching item, replace
the URL with /issues/<id> so the link still resolves to something
meaningful.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:43:26 +08:00
Bohan Jiang
702156904a fix(views): use createSafeId in custom args tab (#1247)
crypto.randomUUID() is only defined in secure contexts, so self-hosted
HTTP deployments were throwing TypeError on mount and when clicking Add.
Route the id generation through the existing createSafeId() helper so
the tab works in non-secure contexts too.

Fixes #1214
2026-04-17 13:42:55 +08:00
joyanup
3ea6b5c7b8 fix(agent): return 409 on duplicate agent name (#1182)
- Migration 046 adds UNIQUE(workspace_id, name) with dedup (keep most recently updated)
- CreateAgent handler returns 409 Conflict scoped to constraint name agent_workspace_name_unique
- Dedup verified as (0 rows) against worktree DB; rerun against staging/production before applying
- Down migration drops the constraint only; deleted rows and cascaded data are not restored

Co-authored-by: Anup Joy <joyanup@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 13:40:02 +08:00
LinYushen
c22a9bd88e fix(runtimes): skip CLI update prompts for desktop-managed runtimes (#1243)
Desktop-launched daemons have their CLI binary overwritten by the Desktop
app on every launch, so any in-app update is reset. The detail panel already
renders 'Managed by Desktop' and hides the Update button when
metadata.launched_by === 'desktop', but the sidebar red dot
(useMyRuntimesNeedUpdate) and the list arrow (useUpdatableRuntimeIds) still
flagged them because runtimeNeedsUpdate() only considered mode/owner/version.

Short-circuit runtimeNeedsUpdate() on launched_by === 'desktop' so all three
surfaces (sidebar dot, list arrow, detail panel) agree and defer CLI
upgrades to the Desktop auto-updater.

Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 12:55:06 +08:00
LinYushen
dcd050ca69 fix(desktop): set electron-builder publishingType to release (#1242)
Our CLI release flow pre-creates a *published* GitHub Release via
`gh release create`. electron-builder's default `publishingType: draft`
conflicts with `existingType=release` and causes the DMG/ZIP/blockmaps/
latest-mac.yml uploads to be silently skipped, which breaks
electron-updater auto-update on installed clients (observed on v0.2.4,
had to fall back to `gh release upload` manually).

Explicitly setting `publishingType: release` aligns electron-builder
with our release flow so desktop artifacts are uploaded to the existing
published release automatically.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 12:31:12 +08:00
Naiyuan Qing
80a24bf627 refactor(desktop): tabs are per-workspace, not cross-workspace (#1239)
* refactor(desktop): tabs are per-workspace, not cross-workspace

Tabs are now grouped by workspace in the store; the TabBar shows only the
active workspace's tabs, and switching workspace swaps the visible group.
Before this change tabs were a flat list that spanned workspaces, which
produced a confusing experience: working in acme with three tabs, then
switching to butter and back, still showed whatever tabs you happened to
open while you were in butter alongside your acme work.

The bug had the same shape as the pre-workspace-overlay bug we fixed in
#1237 — a concept ("workspace") was encoded in data (tab paths) but
ignored by the UI that displayed it (TabBar). The fix is structural:
make the data model match the concept.

Key changes:

- **Schema**: `{ activeWorkspaceSlug, byWorkspace: {slug: {tabs, activeTabId}} }`.
  The invariant "every tab belongs to a workspace group" is enforced at
  sanitize time and at migration time; there is no longer a root `/`
  sentinel.
- **NavigationAdapter** detects cross-workspace pushes and delegates to
  `switchWorkspace(slug, path)` instead of navigating the active tab's
  router. All existing call sites in shared code (sidebar dropdown,
  settings post-delete redirect, invite-accept, cmd+k) keep calling
  `push(paths.workspace(x).issues())` unchanged.
- **TabContent** renders only the active workspace's tabs under Activity.
  Cross-workspace state preservation is an explicit non-goal — switching
  workspaces should feel like switching.
- **WorkspaceRouteLayout** auto-heal no longer navigates the tab router
  to `/`. Stale-slug cleanup is a store-level op (`validateWorkspaceSlugs`)
  that drops the whole stale group in one go.
- **App.tsx** bootstrap seeds `activeWorkspaceSlug` when null and the
  user has workspaces; the new-workspace overlay opens/closes based on
  workspace count independently of any route.
- **Persistence migration** (v1 → v2) groups old flat tabs by extracted
  slug, drops root / transition / reserved-slug tabs, and picks an
  active workspace from the old active tab's owning group. No data
  loss for existing users with workspace-scoped tabs.

Web is unchanged — tabs are a desktop-only concept. `packages/views`,
`packages/core`, `apps/web` are all untouched. `setCurrentWorkspace`
in core remains the single source of truth for the API client's
workspace header, driven by `WorkspaceRouteLayout` as before.

Tests: 19 tab-store tests (sanitize, migration, switchWorkspace,
validate, close-last-reseeds, reset). 38 desktop tests total pass.

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

* review: stable selectors + defensive guards on tab-store

Addresses self-review findings on #1239.

**C1 — perf cliff from unstable selector returns.** The previous
`useActiveTab()` selector used `.find()` inside, so every router tick
on the active tab (which replaces the Tab object via immutable spread
in updateTab / updateTabHistory) forced every subscriber to re-render.
Replaced with finer-grained selectors:

  - `useActiveTabIdentity()` — { slug, tabId } primitives (stable across
    unrelated updates).
  - `useActiveTabRouter()` — stable object reference for a tab's lifetime.
  - `useActiveTabHistory()` — { historyIndex, historyLength } numbers.

`useTabHistory` and `DesktopNavigationProvider` now consume the
primitive selectors, so back/forward buttons don't churn on every
path change. A non-hook `getActiveTab(state)` helper covers the
event-handler case.

**I1 — `switchWorkspace` no-ops on empty slug.** Defensive guard in
case a malformed path ever reaches the adapter's detector.

**I2 — merge warns on path/slug mismatch.** Previously silent drop;
now `console.warn` makes the condition visible during debugging.

**Misc — TabRouterInner takes `tab` prop directly.** Passing the Tab
object eliminates a redundant store read per rendered tab.

Known follow-up (not this PR): `packages/core/realtime/use-realtime-sync.ts`
still uses `window.location.assign` for workspace-deleted eviction —
that's a full renderer reload on desktop, which post-refactor wastes
the careful in-memory tab state we just set up. Fixing cleanly requires
a navigation-callback injection pattern through CoreProvider, which is
cross-cutting and deserves its own PR.

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

* fix(workspace): navigate away BEFORE leave/delete mutation to avoid CancelledError

Symptom: deleting the current workspace logged "current workspace
deleted, switching" from the realtime handler and surfaced an
"Uncaught (in promise) CancelledError" from TanStack Query's
refetchQueries batch.

Root cause: a three-way race between the mutation's own
invalidateQueries(workspaceKeys.list()), the settings page's
navigateAwayFromCurrentWorkspace() fetchQuery, and the realtime
workspace:deleted handler's relocateAfterWorkspaceLoss fetchQuery.
All three refetched the same query concurrently; TanStack Query
cancelled the in-flight loser(s), and the rejection bubbled out of
invalidateQueries as an unhandled promise rejection.

Fix: invert the order. Compute the destination from the current
cached workspace list, navigate immediately, *then* fire the
mutation. By the time the backend fires workspace:deleted, the
active workspace is already something else — the realtime handler's
"current === deleted" check fails and its relocate branch no-ops.
Only one refetch happens (the mutation's onSettled), no race, no
cancellation.

navigateAwayFromCurrentWorkspace no longer needs async/fetchQuery
since it reads from cache and returns before the mutation fires.

Applies to both Leave and Delete flows. Both web and desktop benefit
since the code is in packages/views.

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

* fix(desktop): clear workspace singleton + flex drag strip + defer seeding

Three issues that the last round of delete-workspace fixes missed.

**1. `setCurrentWorkspace` singleton leaks after delete.** Navigating
before the mutation (prior fix) changed the URL but nothing cleared
the core platform's currentSlug/currentWsId singleton. Three
downstream consumers still believed the deleted workspace was active:

  - `useRealtimeSync`'s `workspace:deleted` handler: its
    `getCurrentWsId() === deleted` check fired, triggering a parallel
    relocate that raced the mutation's invalidate and the settings
    page's navigate — CancelledError + `window.location.assign`
    (white screen reload).
  - Chrome gating: `{slug && <AppSidebar />}` stayed truthy, the
    sidebar mounted, and `useWorkspaceId` inside it threw because the
    workspace was gone from the list cache.
  - API client's `X-Workspace-Slug` header: stale on the next call.

Fix: `navigateAwayFromCurrentWorkspace` now calls
`setCurrentWorkspace(null, null)` before pushing. The next
workspace's `WorkspaceRouteLayout` re-sets the singleton when it
mounts; for the last-workspace case, null is the correct state
(overlay has no workspace context).

Same family as the previous logout bug: persist only writes to
storage, reset on logout must also wipe in-memory state. Here the
singleton is another in-memory bit that survives a URL change if
we don't explicitly clear it.

**2. "Cannot update a component while rendering" warning.** The
per-workspace-tabs refactor kept the validate+seed call in render
phase (matching the pre-refactor pattern). It worked before because
`validateWorkspaceSlugs` is idempotent; the new `switchWorkspace`
seed is not, and triggers a TabBar re-render during AppContent's
render. Moved to `useLayoutEffect` — synchronously after render,
before paint, no flicker.

**3. Welcome-screen drag region didn't work on desktop.** The
absolute-positioned `h-10 z-10` drag strip relied on z-index stacking
to beat the content wrapper's no-drag for hit-testing, which wasn't
reliable for `-webkit-app-region` on the overlay. Replaced with a
flex child (`h-12 shrink-0` at top of the overlay's flex-col), so
the drag region owns its own layout space — any pixel in the top 48
is unambiguously drag.

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

* docs(CLAUDE): desktop-specific rules — routing, singleton, drag, UX split

Codifies the lessons from the recent desktop refactor series
(#1237, #1238, #1239) so future work doesn't re-derive them from
bugs. Covers:

- **Route categories** (session / transition / error) — explains why
  `/workspaces/new` and `/invite/:id` are overlay state, not routes,
  on desktop; stale slugs auto-heal instead of rendering error pages.
- **`setCurrentWorkspace` singleton hygiene** — unmount doesn't
  clear it; any code leaving workspace context must call
  `setCurrentWorkspace(null, null)` explicitly.
- **Workspace destructive operations ordering** — navigate first,
  mutate after, to avoid the three-way refetch race that surfaces
  as CancelledError + full-page reload.
- **Tab isolation** — tabs are grouped per workspace; cross-workspace
  push is intercepted by the navigation adapter and translated into
  switchWorkspace.
- **Drag region pattern** — flex child at top, not absolute overlay;
  `-webkit-app-region` hit-testing is unreliable with z-index stacking.
- **UX vs platform chrome split** — UX affordances (Back, Log out,
  welcome copy) in packages/views/; platform chrome (drag, immersive
  mode, tab system) in desktop-only code.

Also patches the Cross-Platform Development Rules' rule #2 which
previously said "add a route in both apps" unconditionally — added
the exception for pre-workspace transition flows pointing at the
new Desktop-specific Rules section.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:15:54 +08:00
Naiyuan Qing
65e2bf937e fix(editor): rewrite bubble menu with @floating-ui/dom for scroll hiding (#1240)
* fix(editor): prevent duplicate image attachments showing as file cards

Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.

Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
  only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
  where a sibling is already referenced inline — handles old data

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

* fix(editor): rewrite bubble menu with @floating-ui/dom for reliable scroll hiding

Replace Tiptap's native <BubbleMenu> with custom @floating-ui/dom positioning.
The native plugin's virtual element lacked contextElement, so the hide middleware
could only detect viewport clipping — not nested scroll container clipping
(e.g. comment/reply inputs inside a scrollable page).

Key changes:
- contextElement on virtual reference enables hide middleware to detect ALL
  overflow ancestor clipping, not just viewport bounds
- visibility:hidden (not display:none) keeps element measurable for
  computePosition, fixing the comma-selection positioning bug
- autoUpdate monitors all scroll ancestors automatically via contextElement
- Remove: getScrollParent, scrollHiddenRef, manual scroll listener, scrollTarget
- Remove @tiptap/extension-bubble-menu dependency (no longer used)

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:57:29 +08:00
Naiyuan Qing
763c0cd25f feat(workspace): typed delete confirm + sole-owner leave preflight (#1238)
* feat(workspace): typed delete confirm + sole-owner leave preflight

Harden the Danger Zone on the Workspace settings tab.

**Delete workspace** now requires typing the workspace name exactly
(case-sensitive, no trimming) before the destructive button enables —
GitHub's repo-delete pattern. Deleting cascades into every issue,
agent, skill, and run under the workspace with no soft-delete, so the
friction is deliberate. Enter submits only when matched; the input
clears on close so reopening for a different workspace doesn't leak
the prior attempt.

**Leave workspace** now preflights the sole-owner case the backend
already blocks (server/internal/handler/workspace.go:569 — "workspace
must have at least one owner"). Previously the user clicked Confirm
and got an opaque 400 toast; now the Leave button is disabled upfront
with inline guidance that distinguishes:
 - sole member: "Delete the workspace to leave."
 - sole owner with other members: "Promote another member to owner
   first, or delete the workspace."

Both changes live in packages/views/, so web and desktop get the same
Danger Zone treatment automatically.

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

* review: gate Danger Zone on members fetched + reset typed input on rename

Addresses self-review findings on #1238:

- Previously the Danger Zone rendered immediately with `members = []`, so
  the Delete workspace block (gated on `isOwner`, which is derived from
  an empty members list) would flash in once the query settled. Gate the
  whole section on `membersFetched` so it appears once with correct
  controls.
- Reset `typed` on `workspaceName` change too — if another owner renames
  the workspace while the dialog is open, the already-typed string stops
  matching silently; resetting surfaces the mismatch.
- Added two tests: unicode/special-char names match literally; rename
  mid-dialog clears the input.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:15:58 +08:00
Naiyuan Qing
c2f7dc49f8 refactor(desktop): model pre-workspace flows as window overlays, not tab routes (#1237)
Previously /workspaces/new and /invite/:id were tab routes on desktop.
That meant the TabBar rendered on top of flows that conceptually aren't
"places" the user sits at — creating a workspace or accepting an invite
is a one-shot transition, not a session. The mismatch also produced
several downstream bugs: tab state persisted these paths, the invite
deep link had no clean dispatch target, and NoAccessPage leaked TabBar
chrome when a workspace slug went stale.

Fix by recognising the underlying category mistake: on desktop, these
flows are application state, not routes. Move them to a window-level
overlay driven by a small Zustand store; the navigation adapter
intercepts pushes to the corresponding paths and routes them to the
overlay instead. Web keeps the routes (users need shareable URLs and
back-button semantics), so shared view components are reused as-is.

UX affordances (Back button when dismissable, Log out escape) live in
the shared NewWorkspacePage/InvitePage so both platforms render
identical content; the desktop overlay is now a thin platform shell
(drag strip + useImmersiveMode) that wraps the shared UX. Web wires
onBack based on whether the user has any workspaces.

Also addresses several related issues uncovered along the way:
- Logout now resets the in-memory tab + overlay stores (previously only
  localStorage was cleared, so the next login inherited the prior
  user's tabs).
- WorkspaceRouteLayout auto-heals a stale workspace slug by navigating
  to "/" instead of rendering NoAccessPage — on desktop without a URL
  bar, "no access" is always stale state, not a legitimate destination.
- IndexRedirect overlay lifecycle is bidirectional: opens when wsList
  is empty, closes when it becomes non-empty (realtime workspace:added
  would otherwise leave the overlay stuck open).
- tryRouteToOverlay resets the current tab to "/" when opening the
  new-workspace overlay; otherwise workspace-scoped components under
  the overlay continue to render and throw when the workspace they
  reference disappears from the cache (reproducible by deleting the
  last workspace from Settings).
- handleDeepLink now accepts multica://invite/<id>, IPC'd through to
  the renderer and opened as an invite overlay. Email template still
  links to https:// (unchanged), but the desktop dispatch path is now
  wired for a future "open in desktop app" bridge.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:55:10 +08:00
Naiyuan Qing
0a1c82730f fix(editor): prevent duplicate image attachments showing as file cards (#1231)
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.

Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
  only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
  where a sibling is already referenced inline — handles old data

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:49:29 +08:00
Jiayuan Zhang
7dc37e87df fix(autopilot): subscribe creator to autopilot-created issues (#1229)
The issue:created subscriber listener type-asserted payload["issue"] to
handler.IssueResponse, but autopilot publishes the issue as
map[string]any (via service.issueToMap). The assertion failed silently,
so no subscribers (including the creator) were ever added to autopilot
issues — meaning creators received no notifications when their
autopilot run produced comments or status changes.

Add an extractIssueFields helper that accepts either format and use it
in both the issue:created and issue:updated listeners. Mirrors the
dual-format pattern already used by the comment:created listener.
2026-04-17 10:05:43 +08:00
Jiayuan Zhang
cf8a9647bb refactor(autopilot): make status a toggle instead of a pause/activate button (#1228)
Replace the Pause/Activate button on the detail page with a Switch next
to the title, showing a colored status label. Flipping it toggles
between active and paused via the existing updateAutopilot mutation.
2026-04-17 10:05:10 +08:00
Jiayuan Zhang
d7a8e9041e refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority (#1227)
* refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority

- Drop GitHub button from hero CTAs (already in header) so the primary
  Start / Download Desktop pair is the clear path.
- Split InstallCommand: outer is no longer a <button>, so text selection
  no longer fights with copy. Mobile gets full-width with break-all;
  desktop keeps the compact pill. Copy button has aria-label.
- Fix invalid `hover:bg-white/8` opacity to `hover:bg-white/[0.08]` so
  the install pill's hover background actually renders.
- Add `flex-wrap` and gap-y to the "Works with" row so the label + 5
  logos can stack on small screens instead of overflowing horizontally.
- Move `priority` from the decorative backdrop image onto the product
  hero image (the actual LCP candidate) to stop background bytes from
  starving the foreground.

* refactor(landing): remove install command from hero

Per design feedback, the install command pill is removed from the hero.
The download path now flows through the Download Desktop CTA only;
install instructions remain available in the docs and README.
2026-04-17 09:37:43 +08:00
Jiayuan Zhang
3b7abae5b4 refactor(search): collapse cmd+k empty-state commands to primary action (#1225)
Previously every registered Command (New Issue, New Project, three theme
switches, plus contextual Copy actions on issue pages) surfaced on empty
query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency
commands (theme, copy, New Project) are now revealed by typing, matching
the progressive-disclosure pattern already used for Pages and Switch
Workspace. Refs MUL-991.
2026-04-17 09:09:55 +08:00
Jiayuan Zhang
7843da0315 refactor(issues): lighten board card styling (#1217)
Slimmer 0.5px border, 12/10 asymmetric padding, and a two-layer soft
drop shadow give the kanban card a more weightless look on the board.
2026-04-17 02:15:24 +08:00
Jiayuan Zhang
caa18a6983 feat(search): extend cmd+k palette (theme toggle, new issue/project, copy link, switch workspace) (#1208)
* feat(search): add light/dark/system theme toggle actions to cmd+k

The command palette now surfaces an "Actions" section with theme toggle
items (Light / Dark / System), searchable via keywords like "theme",
"light", "dark", "appearance", or "mode". The active theme is marked
with a check icon.

* feat(search): add quick-win commands to cmd+k palette

Extends the command palette with a "Commands" group that consolidates
theme toggles plus four new actions:

- New Issue / New Project — trigger the global create modals
- Copy Issue Link / Copy Identifier (MUL-xxx) — only when the current
  route is an issue detail page; mirrors the copy-link dropdown logic
  from issue-detail

Adds a "Switch Workspace" group that lists the user's other workspaces
(filtered by name/slug, or by typing "workspace"/"switch") and
navigates to the selected workspace's issues page.

To make "New Project" work from anywhere, the inline CreateProjectDialog
on ProjectsPage is extracted into a global CreateProjectModal mounted
via the existing ModalRegistry + modal store (same pattern as
create-issue / create-workspace). The modal store type gains a
"create-project" variant.

* feat(search): show Commands by default so they're discoverable

Before, cmd+k actions (New Issue / New Project / Copy link / Copy ID /
theme toggles) only appeared when the user typed a matching keyword,
leaving them invisible unless the user already knew they existed.

Now the Commands group renders as soon as the palette opens (no query),
with the whole command list shown; typing narrows it down as before.
Also trims the redundant "⌘K to open this anytime" hint from the empty
state — the palette is already open.
2026-04-17 02:03:03 +08:00
Jiayuan Zhang
6e980925cf chore(desktop): DESKTOP_APP_SUFFIX env for parallel-worktree dev (#1215)
Dev Electron uses a single userData path ("Multica Canary") derived from
the app name, which also locates the single-instance lock. Two worktrees
running dev simultaneously fight for that lock — the second `app.quit()`s
silently before opening a window.

DESKTOP_APP_SUFFIX appends to the app name + userData path so each
worktree can claim its own lock:

  DESKTOP_APP_SUFFIX=foo  → "Multica Canary foo"

Default (no env var) keeps behavior unchanged.

Complements the existing DESKTOP_RENDERER_PORT env from #1210 so a full
"run a second dev Electron" setup looks like:

  DESKTOP_RENDERER_PORT=15173 DESKTOP_APP_SUFFIX=foo pnpm dev:desktop
2026-04-17 01:55:30 +08:00
Jiayuan Zhang
8bc20ce161 feat(issues): add newly created issue to cmd+k Recent list (#1213)
Hooks recordVisit into useCreateIssue onSuccess so issues the user just
created appear in cmd+k's Recent section without requiring them to open
the issue first.
2026-04-17 01:45:19 +08:00
180 changed files with 10580 additions and 1921 deletions

View File

@@ -6,6 +6,16 @@ POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Server
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
# "production" by default, so 888888 is DISABLED — a public instance can't
# be logged into with any email + 888888.
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
# - Docker self-host on a private network you fully control, or evaluation
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
# enable on a publicly reachable instance.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
@@ -22,7 +32,8 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
# master code 888888 works (only when APP_ENV != "production"; see above).
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -63,3 +74,18 @@ NEXT_PUBLIC_WS_URL=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to completely disable new user signups (recommended for private instances)
ALLOW_SIGNUP=true
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
NEXT_PUBLIC_ALLOW_SIGNUP=true
# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=
# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=

View File

@@ -30,7 +30,7 @@ jobs:
run: pnpm install
- name: Build, type check, and test
run: pnpm build && pnpm typecheck && pnpm test
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
backend:
runs-on: ubuntu-latest

View File

@@ -3,7 +3,8 @@ name: Release
on:
push:
tags:
- "v*"
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
permissions:
contents: write
@@ -17,6 +18,15 @@ jobs:
with:
fetch-depth: 0
- name: Validate tag name
run: |
tag="${GITHUB_REF_NAME}"
echo "Triggered by tag: $tag"
if [[ "$tag" == *-dirty* ]]; then
echo "::error::Refusing to release from dirty tag '$tag'."
exit 1
fi
- name: Setup Go
uses: actions/setup-go@v5
with:

View File

@@ -162,7 +162,7 @@ When the two apps need different behavior for the same concept (e.g., different
When adding a new page or feature:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
@@ -176,6 +176,70 @@ Both apps share the same CSS foundation from `packages/ui/styles/`.
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace identity singleton
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
1. API client's `X-Workspace-Slug` header.
2. Zustand per-workspace storage namespace.
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order:
1. Read destination from cached workspace list (no extra fetch).
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
4. THEN `await mutation.mutateAsync(workspaceId)`.
Reversing step 4 with steps 13 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS window-move)
Every full-window desktop view (login, overlay, any page that covers the native title bar) needs a top drag strip so users can move the window. On macOS the traffic lights are hidden via `useImmersiveMode` in overlay-style contexts, so the drag strip also gives back that corner for pointer-drag.
**Pattern**: flex child at top, not absolute overlay.
```tsx
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="h-12 shrink-0" style={{ WebkitAppRegion: "drag" }} />
<div className="flex-1 overflow-auto" style={{ WebkitAppRegion: "no-drag" }}>
{/* page content — interactive elements need their own "no-drag" */}
</div>
</div>
```
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Height matches `MainTopBar` (48px / `h-12`) for consistency.
Canonical examples: `components/window-overlay.tsx`, `pages/login.tsx`.
### UX vs platform chrome
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (drag strip, `useImmersiveMode`, tab system interaction, traffic-light accommodation) lives in desktop-only code. Violating this split always produces platform divergence — if a button exists on desktop but not on web for the same flow, it's a signal the UX escaped into platform code.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.

View File

@@ -278,7 +278,7 @@ multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
### Get Issue
@@ -293,7 +293,7 @@ multica issue get <id> --output json
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
### Update Issue
@@ -332,6 +332,27 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
### Subscribers
```bash
# List subscribers of an issue
multica issue subscriber list <issue-id>
# Subscribe yourself to an issue
multica issue subscriber add <issue-id>
# Subscribe another member or agent by name
multica issue subscriber add <issue-id> --user "Lambda"
# Unsubscribe yourself
multica issue subscriber remove <issue-id>
# Unsubscribe another member or agent
multica issue subscriber remove <issue-id> --user "Lambda"
```
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
### Execution History
```bash
@@ -349,6 +370,70 @@ multica issue run-messages <task-id> --since 42 --output json
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
### List Projects
```bash
multica project list
multica project list --status in_progress
multica project list --output json
```
Available filters: `--status`.
### Get Project
```bash
multica project get <id>
multica project get <id> --output json
```
### Create Project
```bash
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
### Update Project
```bash
multica project update <id> --title "New title" --status in_progress
multica project update <id> --lead "Lambda"
```
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
### Change Status
```bash
multica project status <id> in_progress
```
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
### Delete Project
```bash
multica project delete <id>
```
### Associating Issues with Projects
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
project, or on `issue list` to filter issues by project:
```bash
multica issue create --title "Login bug" --project <project-id>
multica issue update <issue-id> --project <project-id>
multica issue list --project <project-id>
```
## Setup
```bash
@@ -385,6 +470,63 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
### List Autopilots
```bash
multica autopilot list
multica autopilot list --status active --output json
```
### Get Autopilot Details
```bash
multica autopilot get <id>
multica autopilot get <id> --output json # includes triggers
```
### Create / Update / Delete
```bash
multica autopilot create \
--title "Nightly bug triage" \
--description "Scan todo issues and prioritize." \
--agent "Lambda" \
--mode create_issue
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger
```bash
multica autopilot trigger <id> # Fires the autopilot once, returns the run
```
### Run History
```bash
multica autopilot runs <id>
multica autopilot runs <id> --limit 50 --output json
```
### Schedule Triggers
```bash
multica autopilot trigger-add <autopilot-id> --cron "0 9 * * 1-5" --timezone "America/New_York"
multica autopilot trigger-update <autopilot-id> <trigger-id> --enabled=false
multica autopilot trigger-delete <autopilot-id> <trigger-id>
```
Only cron-based `schedule` triggers are currently exposed via the CLI. The data model also defines `webhook` and `api` kinds, but there is no server endpoint that fires them yet, so they're not surfaced here.
## Other Commands
```bash

View File

@@ -66,7 +66,8 @@ selfhost:
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in with any email + verification code: 888888"; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \

View File

@@ -26,7 +26,7 @@ multica setup self-host
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -63,9 +63,13 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
### Step 3 — Install CLI & Start Daemon

View File

@@ -23,7 +23,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
### Google OAuth (Optional)

View File

@@ -42,4 +42,10 @@ publish:
provider: github
owner: multica-ai
repo: multica
# Align with our CLI release flow which pre-creates a *published* GitHub
# Release via `gh release create`. The electron-builder default of
# `releaseType: draft` conflicts with `existingType=release` and causes
# uploads of the DMG/ZIP/blockmaps/latest-mac.yml to be silently skipped,
# which breaks electron-updater auto-update on installed clients.
releaseType: release
npmRebuild: false

View File

@@ -10,4 +10,28 @@ export default [
globals: { ...globals.node },
},
},
// Security: every renderer-controlled URL that reaches the OS shell must
// flow through openExternalSafely in src/main/external-url.ts (scheme
// allowlist). Enforce it statically so a direct shell.openExternal call
// cannot silently regress the protection.
{
files: ["src/main/**/*.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"CallExpression[callee.object.name='shell'][callee.property.name='openExternal']",
message:
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
},
],
},
},
{
files: ["src/main/external-url.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
];

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("electron", () => ({
shell: { openExternal: vi.fn().mockResolvedValue(undefined) },
}));
import { shell } from "electron";
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
describe("isSafeExternalHttpUrl", () => {
it("allows http and https URLs", () => {
expect(isSafeExternalHttpUrl("https://multica.ai")).toBe(true);
expect(isSafeExternalHttpUrl("http://localhost:3000/auth")).toBe(true);
});
it("allows https URLs with embedded credentials", () => {
// WHATWG URL parses these as https; OS-level handling is the shell's concern.
expect(isSafeExternalHttpUrl("https://user:pass@example.com")).toBe(true);
});
it("normalizes scheme casing so uppercase variants can't bypass", () => {
expect(isSafeExternalHttpUrl("HTTPS://example.com")).toBe(true);
expect(isSafeExternalHttpUrl("FILE:///etc/passwd")).toBe(false);
});
it("rejects dangerous pseudo-schemes", () => {
expect(isSafeExternalHttpUrl("javascript:alert(1)")).toBe(false);
expect(
isSafeExternalHttpUrl("data:text/html,<script>alert(1)</script>"),
).toBe(false);
});
it("rejects filesystem and network transport schemes", () => {
expect(isSafeExternalHttpUrl("file:///etc/passwd")).toBe(false);
expect(isSafeExternalHttpUrl("ftp://example.com/x")).toBe(false);
expect(isSafeExternalHttpUrl("smb://share/x")).toBe(false);
});
it("rejects local-handler schemes used in past RCE chains", () => {
expect(isSafeExternalHttpUrl("vscode://file/test")).toBe(false);
expect(isSafeExternalHttpUrl("ms-msdt:/id%20PCWDiagnostic")).toBe(false);
});
it("rejects mailto and other non-web schemes", () => {
expect(isSafeExternalHttpUrl("mailto:test@example.com")).toBe(false);
expect(isSafeExternalHttpUrl("tel:+15551234567")).toBe(false);
});
it("rejects empty, whitespace, and malformed input", () => {
expect(isSafeExternalHttpUrl("")).toBe(false);
expect(isSafeExternalHttpUrl(" ")).toBe(false);
expect(isSafeExternalHttpUrl("not a url")).toBe(false);
expect(isSafeExternalHttpUrl("http://")).toBe(false);
});
});
describe("openExternalSafely", () => {
beforeEach(() => {
vi.mocked(shell.openExternal).mockClear();
});
it("forwards http/https URLs to shell.openExternal", () => {
openExternalSafely("https://multica.ai");
expect(shell.openExternal).toHaveBeenCalledWith("https://multica.ai");
});
it("does not call shell.openExternal for rejected schemes", () => {
openExternalSafely("file:///etc/passwd");
openExternalSafely("javascript:alert(1)");
openExternalSafely("not a url");
expect(shell.openExternal).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
import { shell } from "electron";
// True when the URL parses and uses http/https — the only schemes we let
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
// URL parser lowercases the protocol field.
export function isSafeExternalHttpUrl(url: string): boolean {
return getHttpProtocol(url) !== null;
}
// Canonical wrapper around shell.openExternal. All renderer-controlled URLs
// that eventually reach the OS shell MUST flow through here; direct calls
// to `shell.openExternal` elsewhere in the main process are banned by the
// no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
export function openExternalSafely(url: string): Promise<void> | void {
if (getHttpProtocol(url) === null) {
console.warn(`[security] blocked openExternal: ${describeScheme(url)}`);
return;
}
return shell.openExternal(url);
}
function getHttpProtocol(url: string): "http:" | "https:" | null {
try {
const { protocol } = new URL(url);
if (protocol === "http:" || protocol === "https:") return protocol;
return null;
} catch {
return null;
}
}
function describeScheme(url: string): string {
try {
return `scheme=${new URL(url).protocol}`;
} catch {
return "invalid URL";
}
}

View File

@@ -1,10 +1,11 @@
import { app, shell, BrowserWindow, ipcMain, nativeImage } from "electron";
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
@@ -48,6 +49,19 @@ function handleDeepLink(url: string): void {
if (token && mainWindow) {
mainWindow.webContents.send("auth:token", token);
}
return;
}
// multica://invite/<invitationId>
// Dispatched from the web invite page when the user chooses "Open in
// desktop app". The renderer opens the invite overlay — no tab, no
// route persistence, so deep-linking the same invite twice stays safe.
if (parsed.hostname === "invite") {
const id = parsed.pathname.replace(/^\//, "");
if (id && mainWindow) {
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
}
return;
}
} catch {
// Ignore malformed URLs
@@ -91,7 +105,7 @@ function createWindow(): void {
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
openExternalSafely(details.url);
return { action: "deny" };
});
@@ -170,9 +184,13 @@ if (!gotTheLock) {
optimizer.watchWindowShortcuts(window);
});
// IPC: open URL in default browser (used by renderer for Google login)
// IPC: open URL in default browser (used by renderer for Google login).
// All scheme-allowlist enforcement lives in openExternalSafely — this
// is the single audit point for renderer-controlled URLs reaching the
// OS shell under the app's intentional webSecurity: false + sandbox:
// false configuration.
ipcMain.handle("shell:openExternal", (_event, url: string) => {
return shell.openExternal(url);
return openExternalSafely(url);
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen

View File

@@ -1,9 +1,21 @@
import { autoUpdater } from "electron-updater";
import { BrowserWindow, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain } from "electron";
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
const STARTUP_CHECK_DELAY_MS = 5_000;
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export type ManualUpdateCheckResult =
| {
ok: true;
currentVersion: string;
latestVersion: string;
available: boolean;
}
| { ok: false; error: string };
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
const win = getMainWindow();
@@ -37,10 +49,42 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
autoUpdater.quitAndInstall(false, true);
});
// Check for updates after a short delay to avoid blocking startup
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
try {
const result = await autoUpdater.checkForUpdates();
const currentVersion = app.getVersion();
// Trust electron-updater's own decision rather than re-deriving it from
// a version-string compare. The two diverge for pre-release channels,
// staged rollouts, downgrades, and minimum-system-version gates — in
// those cases updateInfo.version differs from app.getVersion() but no
// `update-available` event fires, so showing "available" here would
// promise a download prompt that never appears.
return {
ok: true,
currentVersion,
latestVersion: result?.updateInfo.version ?? currentVersion,
available: result?.isUpdateAvailable ?? false,
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
});
// Initial check shortly after startup so we don't block boot.
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, 5000);
}, STARTUP_CHECK_DELAY_MS);
// Background poll so long-running sessions still pick up new releases
// without requiring the user to restart the app.
setInterval(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Periodic update check failed:", err);
});
}, PERIODIC_CHECK_INTERVAL_MS);
}

View File

@@ -3,6 +3,8 @@ import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
onInviteOpen: (callback: (invitationId: 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. */
@@ -51,6 +53,10 @@ interface UpdaterAPI {
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
checkForUpdates: () => Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
>;
}
declare global {

View File

@@ -11,6 +11,15 @@ const desktopAPI = {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Listen for invitation IDs delivered via deep link */
onInviteOpen: (callback: (invitationId: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
callback(invitationId);
ipcRenderer.on("invite:open", handler);
return () => {
ipcRenderer.removeListener("invite:open", handler);
};
},
/** 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 */
@@ -87,6 +96,10 @@ const updaterAPI = {
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
checkForUpdates: (): Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
> => ipcRenderer.invoke("updater:check"),
};
if (process.contextIsolated) {

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
@@ -11,6 +11,8 @@ import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { UpdateNotification } from "./components/update-notification";
import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
function AppContent() {
const user = useAuthStore((s) => s.user);
@@ -31,6 +33,17 @@ function AppContent() {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
// We open the overlay regardless of login state — if the user isn't logged
// in, InvitePage's queries will fail and render the "not found" state,
// which is acceptable; the expected pre-flight happens in the web app
// (login + next=/invite/... dance) before the deep link is ever dispatched.
useEffect(() => {
return window.desktopAPI.onInviteOpen((invitationId) => {
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
});
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
// daemonAPI.syncToken is handled separately by the [user] effect below, which
// fires whenever a user logs in (deep link, session restore, account switch).
@@ -83,22 +96,40 @@ function AppContent() {
});
const wsCount = workspaces?.length ?? 0;
// Validate persisted tab paths against the current user's workspace list.
// Tabs survive across app restarts and account switches (persisted to
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
// reference a workspace the current user can't access — showing
// NoAccessPage every time they open the app.
//
// Run synchronously in render phase rather than in useEffect so the first
// render already sees validated tabs. useEffect runs AFTER commit, which
// means the initial render would briefly show NoAccessPage before the
// effect resets the tab. Zustand supports render-phase setState; the
// validator is idempotent (exits early if nothing changed) so this
// doesn't loop.
if (workspaces) {
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
// (synchronously after render, before paint) rather than the render
// phase — the original render-phase pattern triggered React's
// "Cannot update a component while rendering a different component"
// warning because `switchWorkspace` is a Zustand setState that the
// TabBar is subscribed to. useLayoutEffect flushes both renders before
// the user sees anything, so there's no visible flicker.
useLayoutEffect(() => {
if (!workspaces) return;
const validSlugs = new Set(workspaces.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
}
const tabStore = useTabStore.getState();
tabStore.validateWorkspaceSlugs(validSlugs);
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
tabStore.switchWorkspace(workspaces[0].slug);
}
}, [workspaces]);
// Bidirectional new-workspace overlay: visible when there are no
// workspaces to enter, hidden as soon as one exists. Gated on
// `workspaceListFetched` so the initial render doesn't flash the
// overlay before the list arrives. The overlay's own `invite` type is
// not touched here — that's an in-flight task owned by the user.
useEffect(() => {
if (!user) return;
if (!workspaceListFetched) return;
const { overlay, open, close } = useWindowOverlayStore.getState();
const isEmpty = wsCount === 0;
if (isEmpty) {
if (!overlay) open({ type: "new-workspace" });
} else if (overlay?.type === "new-workspace") {
close();
}
}, [user, workspaceListFetched, wsCount]);
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
// false = session started with >=1 workspace, OR we've already restarted; skip
@@ -135,9 +166,14 @@ function AppContent() {
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.
// On logout, wipe desktop-only in-memory state and stop the daemon so that
// a subsequent login as a different user never inherits the previous user's
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
// useLogout clears the storage key, but the live stores stay populated until
// we explicitly reset them here.
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
try {
await window.daemonAPI.clearToken();
} catch {

View File

@@ -18,6 +18,7 @@ import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
@@ -113,7 +114,8 @@ export function DesktopShell() {
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
to populate the slug. The sidebar gates on slug being present
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
users are routed to /workspaces/new by IndexRedirect. */}
users see the window-level overlay (new-workspace flow)
triggered by IndexRedirect, not a route. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
@@ -132,6 +134,7 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>
);

View File

@@ -29,8 +29,8 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import { isGlobalPath, paths } from "@multica/core/paths";
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import { paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
@@ -67,16 +67,13 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
const handleClick = () => {
if (isActive) return;
setActiveTab(tab.id);
// No navigate() — Activity handles visibility
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
closeTab(tab.id);
// No navigate() — store handles activeTabId switch
};
// Stop pointer down on close so it doesn't start a drag on the parent button.
const stopDragOnClose = (e: React.PointerEvent) => {
e.stopPropagation();
};
@@ -125,22 +122,13 @@ function NewTabButton() {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const handleClick = () => {
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
// opens in the same context as the active one. Read the slug from the
// active tab's path directly rather than from getCurrentSlug(), because
// that singleton is "last tab to render" (non-deterministic with N tabs
// mounted under <Activity>), while activeTabId is the unambiguous truth.
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
// tab is on a global route (e.g. /workspaces/new, /login).
const { tabs, activeTabId } = useTabStore.getState();
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
let slug: string | null = null;
if (activePath !== "/" && !isGlobalPath(activePath)) {
slug = activePath.split("/").filter(Boolean)[0] ?? null;
}
const path = slug ? paths.workspace(slug).issues() : "/";
// New tab opens in the currently active workspace — tabs are scoped
// per workspace, so there is no cross-workspace ambiguity to resolve.
const activeSlug = useTabStore.getState().activeWorkspaceSlug;
if (!activeSlug) return;
const path = paths.workspace(activeSlug).issues();
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
setActiveTab(tabId);
if (tabId) setActiveTab(tabId);
};
return (
@@ -155,17 +143,17 @@ function NewTabButton() {
}
export function TabBar() {
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
const group = useActiveGroup();
const moveTab = useTabStore((s) => s.moveTab);
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const tabs = group?.tabs ?? [];
const activeTabId = group?.activeTabId ?? "";
const tabIds = tabs.map((t) => t.id);
const handleDragEnd = (event: DragEndEvent) => {
@@ -195,7 +183,7 @@ export function TabBar() {
))}
</SortableContext>
</DndContext>
<NewTabButton />
{group && <NewTabButton />}
</div>
);
}

View File

@@ -1,40 +1,52 @@
import { Activity, useEffect } from "react";
import { RouterProvider } from "react-router-dom";
import { useTabStore } from "@/stores/tab-store";
import { useActiveGroup } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
import type { Tab } from "@/stores/tab-store";
/** Inner wrapper rendered inside each tab's RouterProvider. */
function TabRouterInner({ tabId }: { tabId: string }) {
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
useTabRouterSync(tabId, tab!.router);
/**
* Inner wrapper rendered inside each tab's RouterProvider. The router
* reference is stable for a tab's lifetime, so passing it in directly
* (instead of re-deriving from the store) avoids needless re-renders.
*/
function TabRouterInner({ tab }: { tab: Tab }) {
useTabRouterSync(tab.id, tab.router);
return null;
}
/**
* Renders all tabs using Activity for state preservation.
* Renders the active workspace's tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
*
* When switching workspaces, the previous workspace's tabs unmount entirely
* and the new workspace's tabs mount fresh — cross-workspace state
* preservation is an explicit non-goal (keeping all workspaces' tabs warm
* simultaneously would bloat memory and make workspace switching feel
* anything but "switching").
*/
export function TabContent() {
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
const group = useActiveGroup();
// Sync document.title when switching tabs
// Sync document.title when switching tabs within the active workspace.
useEffect(() => {
const tab = tabs.find((t) => t.id === activeTabId);
if (!group) return;
const tab = group.tabs.find((t) => t.id === group.activeTabId);
if (tab) document.title = tab.title;
}, [activeTabId, tabs]);
}, [group?.activeTabId, group?.tabs]);
if (!group) return null;
return (
<>
{tabs.map((tab) => (
{group.tabs.map((tab) => (
<Activity
key={tab.id}
mode={tab.id === activeTabId ? "visible" : "hidden"}
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tabId={tab.id} />
<TabRouterInner tab={tab} />
</TabNavigationProvider>
</Activity>
))}

View File

@@ -0,0 +1,86 @@
import { useCallback, useState } from "react";
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
type CheckState =
| { status: "idle" }
| { status: "checking" }
| { status: "up-to-date"; currentVersion: string }
| { status: "available"; latestVersion: string }
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const [state, setState] = useState<CheckState>({ status: "idle" });
const handleCheck = useCallback(async () => {
setState({ status: "checking" });
const result = await window.updater.checkForUpdates();
if (!result.ok) {
setState({ status: "error", message: result.error });
return;
}
setState(
result.available
? { status: "available", latestVersion: result.latestVersion }
: { status: "up-to-date", currentVersion: result.currentVersion },
);
}, []);
return (
<div>
<h2 className="text-lg font-semibold">Updates</h2>
<p className="text-sm text-muted-foreground mt-1">
The desktop app checks for new versions automatically once an hour and
shortly after launch.
</p>
<div className="mt-6 divide-y">
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Check for updates</p>
<p className="text-sm text-muted-foreground mt-0.5">
Trigger a check now instead of waiting for the next automatic
poll. Available updates appear as a notification in the corner.
</p>
{state.status === "up-to-date" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<Check className="size-3.5 text-success" />
You&apos;re on the latest version (v{state.currentVersion}).
</p>
)}
{state.status === "available" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<ArrowDownToLine className="size-3.5 text-primary" />
v{state.latestVersion} is available see the download prompt
in the corner.
</p>
)}
{state.status === "error" && (
<p className="text-sm text-destructive mt-2 inline-flex items-center gap-1.5">
<AlertCircle className="size-3.5" />
{state.message}
</p>
)}
</div>
<div className="shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleCheck}
disabled={state.status === "checking"}
>
{state.status === "checking" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking
</>
) : (
"Check now"
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useQuery } from "@tanstack/react-query";
import { useImmersiveMode } from "@multica/views/platform";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Window-level transition overlay: renders above the tab system when the
* user is in a pre-workspace flow (create workspace, accept invite).
*
* This component is a thin **platform shell**:
* - Hands the window-drag strip and macOS traffic-light hiding
* (`useImmersiveMode`) — both are platform-specific, web has neither
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
* doesn't leak through
*
* All UX affordances (Back button, Log out button, welcome copy, invite
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
* components under `packages/views/`, so web and desktop render identical
* content. The platform split is: UX in shared code, chrome here.
*/
export function WindowOverlay() {
const overlay = useWindowOverlayStore((s) => s.overlay);
if (!overlay) return null;
return <WindowOverlayInner />;
}
function WindowOverlayInner() {
const overlay = useWindowOverlayStore((s) => s.overlay);
const close = useWindowOverlayStore((s) => s.close);
const { push } = useNavigation();
const { data: wsList = [] } = useQuery(workspaceListOptions());
useImmersiveMode();
if (!overlay) return null;
// Back is only meaningful when there's somewhere to go — i.e. the user
// has at least one workspace. Zero-workspace users can only Log out or
// complete the flow.
const onBack = wsList.length > 0 ? close : undefined;
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
{/* Window-drag strip. Rendered as a flex *child* (not absolute
overlay) so it owns its own 48px of real layout space — the
prior absolute-positioned approach relied on z-index stacking
to beat the content wrapper's no-drag, which in practice didn't
hit-test reliably for `-webkit-app-region` on the welcome
screen. A real flex row with nothing else in it has no such
ambiguity: any pixel at top-48 is drag, full stop.
Height matches `MainTopBar` (48px) so the drag-to-grab area
feels consistent with the rest of the app. The strip is
invisible; macOS traffic lights would normally sit here but
`useImmersiveMode` has hidden them for the overlay's lifetime. */}
<div
aria-hidden
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div
className="flex-1 min-h-0 overflow-auto"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
{overlay.type === "new-workspace" && (
<NewWorkspacePage
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
)}
{overlay.type === "invite" && (
<InvitePage
invitationId={overlay.invitationId}
onBack={onBack}
/>
)}
</div>
</div>
);
}

View File

@@ -2,11 +2,14 @@ import { useEffect } from "react";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
import { workspaceBySlugOptions } from "@multica/core/workspace";
import {
workspaceBySlugOptions,
workspaceListOptions,
} from "@multica/core/workspace";
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { useTabStore } from "@/stores/tab-store";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
@@ -17,9 +20,13 @@ import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
* guaranteed non-null when called. Two industry-standard identities are
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
*
* If the slug doesn't resolve to any workspace the user has access to,
* we render NoAccessPage instead of silently redirecting — users get
* explicit feedback for stale bookmarks or revoked access.
* Unlike web, desktop never renders a "workspace not available" page: the
* app has no URL bar and no clickable links from outside the session, so
* landing on an inaccessible slug can only mean stale state (a persisted
* tab group for a workspace the current user no longer has access to, or
* active eviction). Both cases resolve by dropping the stale tab group
* from the tab store — the TabBar then renders a different workspace or
* the WindowOverlay takes over (zero valid workspaces).
*/
export function WorkspaceRouteLayout() {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
@@ -27,10 +34,7 @@ export function WorkspaceRouteLayout() {
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
// Workspace routes require auth. If user is unauthenticated (token
// expired, logged out from another tab, etc.), bounce to /login.
// Without this, the layout renders null and the user sees a blank page
// stuck on /{slug}/...
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
useEffect(() => {
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
}, [isAuthLoading, user, navigate]);
@@ -40,36 +44,41 @@ export function WorkspaceRouteLayout() {
enabled: !!user && !!workspaceSlug,
});
const { data: wsList } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
// Feed the URL slug into the platform singleton so the API client's
// X-Workspace-Slug header and persist namespace follow the active tab.
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
// every render (matters on desktop, where N tabs each mount their own
// layout). Rehydrate is the singleton's internal side effect.
// setCurrentWorkspace self-dedupes on slug equality.
if (workspace && workspaceSlug) {
setCurrentWorkspace(workspaceSlug, workspace.id);
}
// Remember whether this slug has resolved before (see hook docs). Gates
// the NoAccessPage render below so active workspace removal doesn't
// flash "Workspace not available" before the navigate lands.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
// Stale-slug auto-heal: when this tab's slug fails to resolve, drop the
// whole workspace group from the tab store. Per-workspace tab grouping
// means the cleanup is a single validator call — the TabContent will
// unmount this tab (and all siblings in the stale group) once the store
// updates. We don't navigate this tab's router because the tab's path
// is scoped to the stale slug; navigating to "/" would create an
// inconsistent "tab in group X with path /" state.
useEffect(() => {
if (!user) return;
if (!listFetched) return;
if (workspace) return;
if (hasBeenSeen) return; // active eviction in flight — let the other path win
if (!wsList) return;
const validSlugs = new Set(wsList.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
}, [user, listFetched, workspace, hasBeenSeen, wsList]);
if (isAuthLoading) return null;
if (!workspaceSlug) return null;
// Don't render children until workspace is resolved. useWorkspaceId()
// throws when the workspace list hasn't populated or the slug is
// unknown — gating here is the single point where that invariant is
// enforced, so every descendant can call useWorkspaceId() safely.
if (!listFetched) return null;
if (!workspace) {
// Active workspace just removed (delete/leave/realtime eviction) —
// navigate is in flight; hold null briefly instead of flashing
// NoAccessPage.
if (hasBeenSeen) return null;
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
// link from a former teammate's workspace) → explicit feedback.
return <NoAccessPage />;
}
if (!workspace) return null; // auto-heal effect above handles the cleanup
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

View File

@@ -1,6 +1,6 @@
import { useCallback } from "react";
import type { DataRouter } from "react-router-dom";
import { useTabStore } from "@/stores/tab-store";
import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
/**
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
@@ -9,32 +9,32 @@ import { useTabStore } from "@/stores/tab-store";
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
/**
* Per-tab back/forward navigation derived from the active tab's history state.
* Replaces the old global useNavigationHistory() hook.
* Per-tab back/forward navigation derived from the active workspace's
* active tab.
*
* Subscribed via primitive selectors so this hook only re-renders when
* the numeric history state actually changes — path ticks on the active
* tab (which don't shift historyIndex) don't churn the back/forward
* buttons.
*/
export function useTabHistory() {
// Return the actual tab object from the store — stable reference.
// Do NOT create a new object in the selector (causes infinite re-renders).
const activeTab = useTabStore((s) =>
s.tabs.find((t) => t.id === s.activeTabId),
);
const router = useActiveTabRouter();
const { historyIndex, historyLength } = useActiveTabHistory();
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
const canGoForward =
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < historyLength - 1;
const goBack = useCallback(() => {
if (!activeTab || activeTab.historyIndex <= 0) return;
popDirectionHints.set(activeTab.router, "back");
activeTab.router.navigate(-1);
}, [activeTab]);
if (!router || historyIndex <= 0) return;
popDirectionHints.set(router, "back");
router.navigate(-1);
}, [router, historyIndex]);
const goForward = useCallback(() => {
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
return;
popDirectionHints.set(activeTab.router, "forward");
activeTab.router.navigate(1);
}, [activeTab]);
if (!router || historyIndex >= historyLength - 1) return;
popDirectionHints.set(router, "forward");
router.navigate(1);
}, [router, historyIndex, historyLength]);
return { canGoBack, canGoForward, goBack, goForward };
}

View File

@@ -2,20 +2,23 @@ import { useEffect } from "react";
import { useTabStore } from "@/stores/tab-store";
/**
* Watches document.title via MutationObserver and updates the active tab's title.
*
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
* This observer picks up the change and syncs it to the tab store.
* Watches document.title via MutationObserver and updates the active tab's
* title. Pages set document.title via TitleSync (route handle.title) or
* useDocumentTitle(). This observer picks up the change and syncs it to
* the tab store.
*/
export function useActiveTitleSync() {
useEffect(() => {
const observer = new MutationObserver(() => {
const title = document.title;
if (!title) return;
const { tabs, activeTabId } = useTabStore.getState();
const activeTab = tabs.find((t) => t.id === activeTabId);
const state = useTabStore.getState();
if (!state.activeWorkspaceSlug) return;
const group = state.byWorkspace[state.activeWorkspaceSlug];
if (!group) return;
const activeTab = group.tabs.find((t) => t.id === group.activeTabId);
if (activeTab && activeTab.title !== title) {
useTabStore.getState().updateTab(activeTabId, { title });
state.updateTab(activeTab.id, { title });
}
});

View File

@@ -5,7 +5,15 @@ import {
type NavigationAdapter,
} from "@multica/views/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { isReservedSlug } from "@multica/core/paths";
import {
useTabStore,
resolveRouteIcon,
useActiveTabIdentity,
useActiveTabRouter,
getActiveTab,
} from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
// Public web app URL — injected at build time via .env.production. Falls
// back to the production host for dev builds so "Copy link" yields a URL
@@ -13,8 +21,77 @@ import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
/**
* Root-level navigation provider for components outside the per-tab RouterProviders
* (sidebar, search dialog, modals, etc.).
* Extract the leading workspace slug from a path, or null if the path isn't
* workspace-scoped (root, login, any reserved prefix).
*/
function extractWorkspaceSlug(path: string): string | null {
const first = path.split("/").filter(Boolean)[0] ?? "";
if (!first) return null;
if (isReservedSlug(first)) return null;
return first;
}
/**
* Intercept navigation to "transition" paths — pre-workspace flows that on
* desktop are rendered as a window-level overlay instead of a tab route.
* Returns `true` if the navigation was handled (caller should NOT proceed).
*
* Side effect: when opening the new-workspace overlay, the tab router is
* ALSO reset to "/". Rationale — the only way a push lands on
* /workspaces/new is that the workspace context is gone (fresh install,
* delete-last, leave-last). Leaving the tab parked on a workspace-scoped
* path would keep those components mounted under the overlay; the next
* render after the list cache updates would then throw (useWorkspaceId
* etc) because the slug no longer resolves.
*/
function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
const overlay = useWindowOverlayStore.getState();
if (path === "/workspaces/new") {
overlay.open({ type: "new-workspace" });
if (router && router.state.location.pathname !== "/") {
router.navigate("/", { replace: true });
}
return true;
}
if (path.startsWith("/invite/")) {
let id = "";
try {
id = decodeURIComponent(path.slice("/invite/".length));
} catch {
return true;
}
if (id) {
overlay.open({ type: "invite", invitationId: id });
return true;
}
}
// Any other navigation cancels a live overlay.
if (overlay.overlay) overlay.close();
return false;
}
/**
* Intercept pushes that change workspace. Returns `true` if the navigation
* was delegated to the tab store (caller should NOT proceed).
*
* This is the entry point that makes shared code platform-agnostic:
* sidebar dropdown, cmd+k "switch workspace", post-delete redirects,
* invite-accept flow — they all call `useNavigation().push(path)` with a
* full workspace URL, and on desktop we translate "target slug differs
* from active" into "switch the tab-group that's visible in the TabBar".
*/
function tryRouteToOtherWorkspace(path: string): boolean {
const targetSlug = extractWorkspaceSlug(path);
if (!targetSlug) return false;
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (targetSlug === activeWorkspaceSlug) return false;
switchWorkspace(targetSlug, path);
return true;
}
/**
* Root-level navigation provider for components outside the per-tab
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
*
* Reads from the active tab's memory router via router.subscribe().
* Does NOT use any react-router hooks — it's above all RouterProviders.
@@ -24,50 +101,61 @@ export function DesktopNavigationProvider({
}: {
children: React.ReactNode;
}) {
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
// Primitive-only subscriptions so this component doesn't re-render on
// unrelated store updates (e.g. an inactive tab's router tick). We
// resolve the active router here only to subscribe once per tab switch.
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
const [pathname, setPathname] = useState(
router?.state.location.pathname ?? "/",
);
// Subscribe to the active tab's router for pathname updates
useEffect(() => {
if (!activeTab) return;
setPathname(activeTab.router.state.location.pathname);
return activeTab.router.subscribe((state) => {
if (!router) {
setPathname("/");
return;
}
setPathname(router.state.location.pathname);
return router.subscribe((state) => {
setPathname(state.location.pathname);
});
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [activeTabId, router]);
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => {
if (path === "/login") {
// DashboardGuard token expired — force back to login screen
useAuthStore.getState().logout();
return;
}
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path);
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
active?.router.navigate(path);
},
replace: (path: string) => {
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path, { replace: true });
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
active?.router.navigate(path, { replace: true });
},
back: () => {
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(-1);
currentActiveTab()?.router.navigate(-1);
},
pathname,
searchParams: new URLSearchParams(),
openInNewTab: (path: string, title?: string) => {
const icon = resolveRouteIcon(path);
// Cross-workspace "open in new tab" switches workspace and opens
// the path there; same-workspace just adds a tab in the current group.
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
store.switchWorkspace(slug, path);
return;
}
const icon = resolveRouteIcon(path);
const tabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(tabId);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),
@@ -77,6 +165,10 @@ export function DesktopNavigationProvider({
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}
function currentActiveTab() {
return getActiveTab(useTabStore.getState());
}
/**
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
* Subscribes to the tab's own router for up-to-date pathname.
@@ -101,16 +193,29 @@ export function TabNavigationProvider({
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => router.navigate(path),
replace: (path: string) => router.navigate(path, { replace: true }),
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (tryRouteToOtherWorkspace(path)) return;
router.navigate(path);
},
replace: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (tryRouteToOtherWorkspace(path)) return;
router.navigate(path, { replace: true });
},
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
const icon = resolveRouteIcon(path);
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
const newTabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(newTabId);
if (slug && slug !== store.activeWorkspaceSlug) {
store.switchWorkspace(slug, path);
return;
}
const icon = resolveRouteIcon(path);
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),

View File

@@ -6,7 +6,6 @@ import {
useMatches,
} from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { ProjectDetailPage } from "./pages/project-detail-page";
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
@@ -20,13 +19,9 @@ 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 { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { Server } from "lucide-react";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
/**
@@ -59,77 +54,28 @@ function PageShell() {
);
}
function NewWorkspaceRoute() {
const nav = useNavigation();
return (
<NewWorkspacePage
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
/>
);
}
/**
* Root index route: resolves the URL-less `/` path to a concrete destination.
*
* Runs both on first login (App.tsx seeded the cache) and on app reopen
* (AuthInitializer seeded the cache). Reading from React Query avoids
* duplicate fetches across tabs — each tab's memory router hits this
* component independently but the query is deduped.
*
* Sends first-time users without any workspace to /workspaces/new,
* everyone else to their first workspace's issues page. Persisted tab
* paths that already carry a workspace slug bypass this component
* entirely.
*/
function IndexRedirect() {
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
// Wait for the query to settle so we don't redirect to /workspaces/new
// on the initial render before the seeded/fetched data arrives.
if (!isFetched) return null;
const firstWorkspace = wsList?.[0];
if (firstWorkspace) {
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
}
return <Navigate to={paths.newWorkspace()} replace />;
}
function InviteRoute() {
const matches = useMatches();
const match = matches.find((m) => (m.params as { id?: string }).id);
const id = (match?.params as { id?: string })?.id ?? "";
return <InvitePage invitationId={id} />;
}
/**
* Route definitions shared by all tabs.
*
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
* slug to a workspace and syncing side-effects (api client, persist namespace,
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
* sit at the top level alongside the workspace wrapper.
* Every tab path is workspace-scoped: `/{slug}/{route}/...`. Pre-workspace
* flows (create workspace, accept invite) are NOT routes — they render as a
* window-level overlay via `WindowOverlay`, dispatched by the navigation
* adapter's transition-path interception. The `activeWorkspaceSlug` in the
* tab store decides which workspace's tabs are visible in the TabBar;
* workspace-less state (zero-workspace user) shows the overlay instead.
*
* The root index route stays as a harmless safety net. With per-workspace
* tabs, nothing should construct a tab at `/` — but if one ever slips
* through (malformed persisted state that dodges the migration, direct
* router.navigate from unforeseen code), the index falls back to null
* rather than 404; App.tsx's bootstrap repoints activeWorkspaceSlug on the
* next render pass.
*/
export const appRoutes: RouteObject[] = [
{
element: <PageShell />,
children: [
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
// list from React Query cache (seeded by AuthInitializer on reopen
// or App.tsx on deep-link login) and bounces to the first
// workspace's issues page — or /workspaces/new if the user has none.
{ index: true, element: <IndexRedirect /> },
{
path: "workspaces/new",
element: <NewWorkspaceRoute />,
handle: { title: "Create Workspace" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
},
{ index: true, element: null },
{
path: ":workspaceSlug",
element: <WorkspaceRouteLayout />,
@@ -185,6 +131,12 @@ export const appRoutes: RouteObject[] = [
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),

View File

@@ -1,23 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
// createTabRouter transitively pulls in route modules that expect a browser
// router context. For pure-function tests we stub it out.
// router context. For pure store tests we stub it to a minimal disposable.
const createTabRouterMock = vi.hoisted(() =>
vi.fn(() => ({
dispose: vi.fn(),
state: { location: { pathname: "/" } },
navigate: vi.fn(),
subscribe: vi.fn(() => () => {}),
})),
);
vi.mock("../routes", () => ({
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
createTabRouter: createTabRouterMock,
}));
import { sanitizeTabPath } from "./tab-store";
import {
sanitizeTabPath,
migrateV1ToV2,
useTabStore,
} from "./tab-store";
beforeEach(() => {
createTabRouterMock.mockClear();
useTabStore.getState().reset();
});
describe("sanitizeTabPath", () => {
it("passes through root sentinel", () => {
expect(sanitizeTabPath("/")).toBe("/");
it("rejects the root sentinel — tabs must be workspace-scoped", () => {
expect(sanitizeTabPath("/")).toBeNull();
expect(sanitizeTabPath("")).toBeNull();
});
it("passes through global paths", () => {
expect(sanitizeTabPath("/login")).toBe("/login");
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
it("silently rejects transition paths (no warn — navigation adapter intercepts them)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/workspaces/new")).toBeNull();
expect(sanitizeTabPath("/invite/abc")).toBeNull();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it("passes through valid workspace-scoped paths", () => {
@@ -25,21 +44,181 @@ describe("sanitizeTabPath", () => {
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
});
it("rejects paths whose first segment is a reserved slug", () => {
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/issues")).toBe("/");
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
expect(sanitizeTabPath("/settings")).toBe("/");
expect(sanitizeTabPath("/issues")).toBeNull();
expect(sanitizeTabPath("/settings")).toBeNull();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
// A workspace owner could legitimately pick "acme-issues" or
// "project-x" as their slug — sanitize must not touch these.
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
});
});
describe("migrateV1ToV2", () => {
it("groups v1 flat tabs by workspace slug", () => {
const v1 = {
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
{ id: "t3", path: "/butter/issues", title: "Issues", icon: "ListTodo" },
],
activeTabId: "t2",
};
const v2 = migrateV1ToV2(v1);
expect(Object.keys(v2.byWorkspace).sort()).toEqual(["acme", "butter"]);
expect(v2.byWorkspace.acme.tabs).toHaveLength(2);
expect(v2.byWorkspace.butter.tabs).toHaveLength(1);
expect(v2.byWorkspace.acme.activeTabId).toBe("t2");
expect(v2.byWorkspace.butter.activeTabId).toBe("t3"); // first tab in group
expect(v2.activeWorkspaceSlug).toBe("acme"); // contained v1.activeTabId
});
it("drops tabs at root / transition / reserved-slug paths", () => {
const v1 = {
tabs: [
{ id: "t1", path: "/", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/workspaces/new", title: "New", icon: "Plus" },
{ id: "t3", path: "/invite/abc", title: "Invite", icon: "Mail" },
{ id: "t4", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
],
activeTabId: "t1",
};
const v2 = migrateV1ToV2(v1);
expect(Object.keys(v2.byWorkspace)).toEqual(["acme"]);
expect(v2.byWorkspace.acme.tabs).toHaveLength(1);
// v1.activeTabId was dropped; active falls back to first group's first tab.
expect(v2.activeWorkspaceSlug).toBe("acme");
expect(v2.byWorkspace.acme.activeTabId).toBe("t4");
});
it("handles empty v1 state gracefully", () => {
const v2 = migrateV1ToV2({ tabs: [], activeTabId: "" });
expect(v2.byWorkspace).toEqual({});
expect(v2.activeWorkspaceSlug).toBeNull();
});
it("handles v1 with no tabs field (corrupted state)", () => {
const v2 = migrateV1ToV2({});
expect(v2.byWorkspace).toEqual({});
expect(v2.activeWorkspaceSlug).toBeNull();
});
});
describe("useTabStore actions", () => {
it("switchWorkspace creates a new group with a default tab on first entry", () => {
useTabStore.getState().switchWorkspace("acme");
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
});
it("switchWorkspace without openPath restores the group's last active tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const acmeProjectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
store.setActiveTab(acmeProjectsId);
// Enter a different workspace then come back
store.switchWorkspace("butter");
expect(useTabStore.getState().activeWorkspaceSlug).toBe("butter");
store.switchWorkspace("acme");
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.activeTabId).toBe(acmeProjectsId);
});
it("switchWorkspace with openPath dedupes into an existing tab with same path", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default /acme/issues
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.switchWorkspace("acme", "/acme/issues");
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(2); // no duplicate created
const activeTab = s.byWorkspace.acme.tabs.find(
(t) => t.id === s.byWorkspace.acme.activeTabId,
);
expect(activeTab?.path).toBe("/acme/issues");
});
it("switchWorkspace with openPath not matching any tab adds a new tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("acme", "/acme/issues/bug-42");
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(2);
const activeTab = s.byWorkspace.acme.tabs.find(
(t) => t.id === s.byWorkspace.acme.activeTabId,
);
expect(activeTab?.path).toBe("/acme/issues/bug-42");
});
it("openTab dedupes by path within the active workspace", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const id1 = store.openTab("/acme/projects", "Projects", "FolderKanban");
const id2 = store.openTab("/acme/projects", "Projects", "FolderKanban");
expect(id1).toBe(id2);
expect(useTabStore.getState().byWorkspace.acme.tabs).toHaveLength(2); // default + projects
});
it("closeTab on the last tab in a workspace reseeds the default tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.closeTab(onlyTabId);
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
});
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
store.switchWorkspace("acme");
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
// Admin removed the user from acme
store.validateWorkspaceSlugs(new Set(["butter"]));
const s = useTabStore.getState();
expect(Object.keys(s.byWorkspace)).toEqual(["butter"]);
expect(s.activeWorkspaceSlug).toBe("butter");
});
it("validateWorkspaceSlugs sets activeWorkspaceSlug to null when all groups are dropped", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.validateWorkspaceSlugs(new Set());
const s = useTabStore.getState();
expect(s.byWorkspace).toEqual({});
expect(s.activeWorkspaceSlug).toBeNull();
});
it("reset wipes the whole store", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
store.reset();
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBeNull();
expect(s.byWorkspace).toEqual({});
});
it("setActiveTab across workspaces also flips the active workspace", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.switchWorkspace("butter");
const acmeTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.setActiveTab(acmeTabId);
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});

View File

@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
import { isReservedSlug } from "@multica/core/paths";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -13,6 +13,7 @@ import { createTabRouter } from "../routes";
export interface Tab {
id: string;
/** Every tab path is workspace-scoped: `/{workspaceSlug}/{route}/...`. */
path: string;
title: string;
icon: string;
@@ -21,33 +22,77 @@ export interface Tab {
historyLength: number;
}
interface TabStore {
export interface WorkspaceTabGroup {
tabs: Tab[];
/** Must be a valid tab.id in `tabs`; the empty-tabs state is transient only. */
activeTabId: string;
}
/** Open a background tab. Deduplicates by path. Returns the tab id. */
interface TabStore {
/**
* The workspace currently visible in the TabBar / TabContent. Null in three
* cases:
* - Fresh install, before any workspace exists or is selected.
* - Logged-out state (reset() wipes it).
* - Every workspace the user had access to got deleted / revoked.
* When null, TabContent renders nothing and the WindowOverlay takes over.
*/
activeWorkspaceSlug: string | null;
/**
* Tab groups keyed by workspace slug. Each slug maps to an independent
* (tabs, activeTabId) pair; switching workspaces swaps the visible set
* without affecting any other group. Cross-workspace tab leakage — the
* bug that drove this refactor — is impossible by construction because
* there is no global tab array anymore.
*/
byWorkspace: Record<string, WorkspaceTabGroup>;
/**
* Switch to a workspace.
* - If the group doesn't exist yet, create it with a single default tab.
* - If `openPath` is given, find a tab with that exact path and activate
* it; otherwise add a new tab and activate it.
* - If `openPath` is omitted, restore the group's last active tab
* (VSCode / Slack behavior — workspaces resume where you left off).
*/
switchWorkspace: (slug: string, openPath?: string) => void;
/** Open-or-activate (dedupes by path) a tab in the active workspace. */
openTab: (path: string, title: string, icon: string) => string;
/** Always create a new tab (no dedup). Returns the tab id. */
/** Always creates a new tab (no dedupe) in the active workspace. */
addTab: (path: string, title: string, icon: string) => string;
/** Close a tab. Disposes router. */
/**
* Close a tab. Finds it across all workspaces (callers like the X button
* only know the tab id, not the owning workspace). If this is the last
* tab in its workspace, reseed a default tab so the invariant
* "every live workspace has at least one tab" holds.
*/
closeTab: (tabId: string) => void;
/** Switch to a tab by id. */
/**
* Activate a tab. Finds it across all workspaces. Sets both the owning
* workspace as active and that group's activeTabId; needed for any code
* path that "jumps" to a tab belonging to a non-active workspace.
*/
setActiveTab: (tabId: string) => void;
/** Update a tab's metadata (path, title, icon — partial). */
/** Patch metadata of a tab (router-sync, title-sync). Finds across groups. */
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Update a tab's history tracking. */
/** Patch history tracking of a tab. Finds across groups. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
/** Reorder within the active workspace's group only. */
moveTab: (fromIndex: number, toIndex: number) => void;
/**
* Reset any tab whose first path segment references a workspace slug the
* current user doesn't have access to. Called after login + workspace list
* is populated (and on every subsequent list change, e.g. realtime
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
* are untouched.
* After the workspace list arrives/changes (login, realtime delete), drop
* any tab group whose slug is no longer in `validSlugs`, and repoint
* `activeWorkspaceSlug` if it pointed at one of the dropped groups.
*/
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
/**
* Wipe everything. Called from logout so the next user doesn't inherit
* the prior user's tabs. Zustand persist only writes to localStorage;
* clearing the storage key alone would leave this live store intact
* until app restart.
*/
reset: () => void;
}
// ---------------------------------------------------------------------------
@@ -67,232 +112,594 @@ const ROUTE_ICONS: Record<string, string> = {
};
/**
* Resolve a route icon from a pathname. Title is NOT determined here — it
* comes from document.title.
* Resolve a route icon from a pathname.
*
* Path shape after the workspace URL refactor:
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
* Tab paths are always workspace-scoped: `/{slug}/{route}/...`, so the route
* segment lives at index 1. Pre-workspace flows (create, invite) are rendered
* by the window overlay, never as tabs.
*
* `isGlobalPath` is the single source of truth for which prefixes are global.
* Title is NOT determined here — it comes from document.title.
*/
export function resolveRouteIcon(pathname: string): string {
const segments = pathname.split("/").filter(Boolean);
const routeSegment = isGlobalPath(pathname)
? (segments[0] ?? "")
: (segments[1] ?? "");
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
}
/** Extract the leading workspace slug from a path, or null if the path
* isn't workspace-scoped (global path, root, or empty). */
function extractWorkspaceSlug(path: string): string | null {
const first = path.split("/").filter(Boolean)[0] ?? "";
if (!first) return null;
if (isReservedSlug(first)) return null;
return first;
}
// ---------------------------------------------------------------------------
// Path sanitization (defensive)
// ---------------------------------------------------------------------------
/**
* Defensive: catch paths that don't belong in the tab store.
*
* Two kinds of rejects:
* 1. **Transition paths** (`/workspaces/new`, `/invite/...`). These are
* pre-workspace flows rendered by the window overlay on desktop, not
* tab routes. The navigation adapter normally intercepts these before
* they reach the store; this guard catches older persisted state.
* 2. **Malformed workspace-scoped paths** like a stray `/issues/abc` that
* was constructed without the workspace prefix. The router would
* interpret `issues` as a workspace slug → NoAccessPage.
*
* Returns null for rejects (caller decides how to recover — usually by
* dropping the tab or substituting a default). Unlike the prior design,
* there is no root "/" sentinel — tabs are always scoped.
*/
export function sanitizeTabPath(path: string): string | null {
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
if (!firstSegment) return null;
if (isReservedSlug(firstSegment)) {
// Don't log for known transition paths — these are legitimate inputs
// at the interception boundary (older persisted state or stale callers).
const isTransition = path === "/workspaces/new" || path.startsWith("/invite/");
if (!isTransition) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Dropping.`,
);
}
return null;
}
return path;
}
// ---------------------------------------------------------------------------
// Tab factory
// ---------------------------------------------------------------------------
function createId(): string {
return createSafeId();
}
function makeTab(path: string, title: string, icon: string): Tab {
return {
id: createId(),
path,
title,
icon,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
}
/** Default entry point for a workspace — its issues list. */
function defaultPathFor(slug: string): string {
return `/${slug}/issues`;
}
function defaultTabFor(slug: string): Tab {
const path = defaultPathFor(slug);
return makeTab(path, "Issues", resolveRouteIcon(path));
}
// ---------------------------------------------------------------------------
// Group helpers
// ---------------------------------------------------------------------------
function findTabLocation(
byWorkspace: Record<string, WorkspaceTabGroup>,
tabId: string,
): { slug: string; group: WorkspaceTabGroup; index: number } | null {
for (const slug of Object.keys(byWorkspace)) {
const group = byWorkspace[slug];
const index = group.tabs.findIndex((t) => t.id === tabId);
if (index >= 0) return { slug, group, index };
}
return null;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
/**
* Sentinel path for new tabs with no explicit destination. The tab store is
* workspace-implicit — it doesn't know which workspace is active, so it can't
* build a `/:slug/issues` path itself. Instead we hand off to the router: `/`
* matches the top-level index route, which redirects to the workspace default
* (slug-aware redirect lives in routes.tsx / App.tsx).
*
* `title` and `icon` on the placeholder tab get overwritten by
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
*/
const DEFAULT_PATH = "/";
function createId(): string {
return createSafeId();
}
/**
* Defensive: catch tab paths that were constructed without a workspace slug
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
* paths would get matched as `workspaceSlug="issues"` by the router and
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
* a valid workspace).
*
* Passes through:
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
* - workspace-scoped paths whose first segment is not a reserved word
*
* Rejects (and rewrites to "/"):
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
* means the caller forgot to prefix the workspace. Logs a warning so the
* buggy call site is easy to find.
*/
export function sanitizeTabPath(path: string): string {
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
if (isReservedSlug(firstSegment)) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Falling back to "/".`,
);
return DEFAULT_PATH;
}
return path;
}
function makeTab(path: string, title: string, icon: string): Tab {
const safePath = sanitizeTabPath(path);
return {
id: createId(),
path: safePath,
title,
icon,
router: createTabRouter(safePath),
historyIndex: 0,
historyLength: 1,
};
}
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
export const useTabStore = create<TabStore>()(
persist(
(set, get) => ({
tabs: [initialTab],
activeTabId: initialTab.id,
activeWorkspaceSlug: null,
byWorkspace: {},
openTab(path, title, icon) {
const { tabs } = get();
const existing = tabs.find((t) => t.path === path);
if (existing) return existing.id;
switchWorkspace(slug, openPath) {
// Defensive no-op if slug is empty/invalid — callers like the
// NavigationAdapter's path-parser should already have filtered
// these, but belt-and-braces keeps garbage out of the store.
if (!slug) return;
const { byWorkspace } = get();
const existing = byWorkspace[slug];
const tab = makeTab(path, title, icon);
set({ tabs: [...tabs, tab] });
return tab.id;
},
// Decide the desired active path for this workspace.
const desiredPath = openPath ?? (existing ? null : defaultPathFor(slug));
addTab(path, title, icon) {
const tab = makeTab(path, title, icon);
set((s) => ({ tabs: [...s.tabs, tab] }));
return tab.id;
},
if (!existing) {
// First time entering this workspace — create the group.
const seedPath =
desiredPath && sanitizeTabPath(desiredPath) === desiredPath
? desiredPath
: defaultPathFor(slug);
const tab = makeTab(seedPath, "Issues", resolveRouteIcon(seedPath));
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { tabs: [tab], activeTabId: tab.id },
},
});
return;
}
closeTab(tabId) {
const { tabs, activeTabId } = get();
// Workspace already has tabs. Either dedupe into an existing tab or
// add a new one (when openPath was supplied and no tab matches it).
if (desiredPath) {
const clean = sanitizeTabPath(desiredPath);
if (clean) {
const match = existing.tabs.find((t) => t.path === clean);
if (match) {
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { ...existing, activeTabId: match.id },
},
});
return;
}
const tab = makeTab(clean, "Issues", resolveRouteIcon(clean));
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: {
tabs: [...existing.tabs, tab],
activeTabId: tab.id,
},
},
});
return;
}
}
const closingTab = tabs.find((t) => t.id === tabId);
// No openPath (or openPath was rejected) — just restore the group.
set({ activeWorkspaceSlug: slug });
},
// Never close the last tab — replace with default
if (tabs.length === 1) {
closingTab?.router.dispose();
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
set({ tabs: [fresh], activeTabId: fresh.id });
return;
}
openTab(path, title, icon) {
const { activeWorkspaceSlug, byWorkspace } = get();
const clean = sanitizeTabPath(path);
if (!activeWorkspaceSlug || !clean) return "";
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return "";
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
const existing = group.tabs.find((t) => t.path === clean);
if (existing) {
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: { ...group, activeTabId: existing.id },
},
});
return existing.id;
}
closingTab?.router.dispose();
const next = tabs.filter((t) => t.id !== tabId);
const tab = makeTab(clean, title, icon);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
tabs: [...group.tabs, tab],
activeTabId: group.activeTabId,
},
},
});
return tab.id;
},
if (tabId === activeTabId) {
const newActive = next[Math.min(idx, next.length - 1)];
set({ tabs: next, activeTabId: newActive.id });
} else {
set({ tabs: next });
}
},
addTab(path, title, icon) {
const { activeWorkspaceSlug, byWorkspace } = get();
const clean = sanitizeTabPath(path);
if (!activeWorkspaceSlug || !clean) return "";
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return "";
setActiveTab(tabId) {
set({ activeTabId: tabId });
},
const tab = makeTab(clean, title, icon);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
tabs: [...group.tabs, tab],
activeTabId: group.activeTabId,
},
},
});
return tab.id;
},
updateTab(tabId, patch) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, ...patch } : t,
),
}));
},
closeTab(tabId) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
updateTabHistory(tabId, historyIndex, historyLength) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
),
}));
},
const closing = group.tabs[index];
closing.router.dispose();
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
},
if (group.tabs.length === 1) {
// Last tab in this workspace — reseed a default so the workspace
// always has at least one tab. Closing a workspace as an explicit
// action is a separate concern (Leave/Delete in Settings).
const fresh = defaultTabFor(slug);
set({
byWorkspace: {
...byWorkspace,
[slug]: { tabs: [fresh], activeTabId: fresh.id },
},
});
return;
}
validateWorkspaceSlugs(validSlugs) {
const { tabs } = get();
let changed = false;
const nextTabs = tabs.map((t) => {
// Skip tabs on non-workspace-scoped paths — nothing to validate.
if (t.path === "/" || isGlobalPath(t.path)) return t;
const nextTabs = group.tabs.filter((t) => t.id !== tabId);
const nextActiveTabId =
group.activeTabId === tabId
? nextTabs[Math.min(index, nextTabs.length - 1)].id
: group.activeTabId;
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
if (validSlugs.has(firstSegment)) return t;
set({
byWorkspace: {
...byWorkspace,
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
},
// Stale slug: dispose the old router and replace with a fresh one
// pointing at `/`. IndexRedirect will send the tab to a valid
// workspace (or /workspaces/new if the user now has none).
changed = true;
t.router.dispose();
return {
...t,
path: DEFAULT_PATH,
title: "Issues",
icon: resolveRouteIcon(DEFAULT_PATH),
router: createTabRouter(DEFAULT_PATH),
historyIndex: 0,
historyLength: 1,
};
});
setActiveTab(tabId) {
const { byWorkspace, activeWorkspaceSlug } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group } = hit;
if (slug === activeWorkspaceSlug && group.activeTabId === tabId) return;
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: { ...group, activeTabId: tabId },
},
});
},
if (!changed) return;
set({ tabs: nextTabs });
},
updateTab(tabId, patch) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, ...patch };
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
updateTabHistory(tabId, historyIndex, historyLength) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, historyIndex, historyLength };
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
const { activeWorkspaceSlug, byWorkspace } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
...group,
tabs: arrayMove(group.tabs, fromIndex, toIndex),
},
},
});
},
validateWorkspaceSlugs(validSlugs) {
const { activeWorkspaceSlug, byWorkspace } = get();
let changed = false;
const nextByWorkspace: Record<string, WorkspaceTabGroup> = {};
for (const slug of Object.keys(byWorkspace)) {
if (validSlugs.has(slug)) {
nextByWorkspace[slug] = byWorkspace[slug];
} else {
changed = true;
for (const t of byWorkspace[slug].tabs) t.router.dispose();
}
}
let nextActive = activeWorkspaceSlug;
if (nextActive && !validSlugs.has(nextActive)) {
nextActive = Object.keys(nextByWorkspace)[0] ?? null;
changed = true;
}
if (!changed) return;
set({ byWorkspace: nextByWorkspace, activeWorkspaceSlug: nextActive });
},
reset() {
const { byWorkspace } = get();
for (const slug of Object.keys(byWorkspace)) {
for (const t of byWorkspace[slug].tabs) t.router.dispose();
}
set({ activeWorkspaceSlug: null, byWorkspace: {} });
},
}),
{
name: "multica_tabs",
version: 1,
version: 2,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
// are dropped — they have no workspace to belong to, and the new
// model's invariant is "every tab lives in a workspace group".
if (version < 2 && persistedState && typeof persistedState === "object") {
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
}
return persistedState as V2Persisted;
},
partialize: (state) => ({
tabs: state.tabs.map(
({ router, historyIndex, historyLength, ...rest }) => rest,
activeWorkspaceSlug: state.activeWorkspaceSlug,
byWorkspace: Object.fromEntries(
Object.entries(state.byWorkspace).map(([slug, group]) => [
slug,
{
activeTabId: group.activeTabId,
tabs: group.tabs.map(
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
rest,
),
},
]),
),
activeTabId: state.activeTabId,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as
| Pick<TabStore, "tabs" | "activeTabId">
| undefined;
if (!persisted?.tabs?.length) return currentState;
const persisted = persistedState as Partial<V2Persisted> | undefined;
if (!persisted?.byWorkspace) return currentState;
const tabs: Tab[] = persisted.tabs.map((tab) => {
// Sanitize persisted paths against reserved-slug rules. Catches
// both pre-refactor paths like "/issues/abc" (missing workspace
// slug) and any other malformed paths that slipped past the
// write-time guard. The defense across makeTab + merge + runtime
// validate ensures stale or malformed paths never reach the
// router.
const path = sanitizeTabPath(tab.path);
return {
...tab,
path,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
});
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
for (const [slug, pGroup] of Object.entries(persisted.byWorkspace)) {
const tabs: Tab[] = [];
for (const pTab of pGroup.tabs) {
const clean = sanitizeTabPath(pTab.path);
// Persisted path may have come from a stale version or a
// manual edit. Drop rather than rewrite so we never silently
// put users on a path that doesn't match the group's slug.
if (!clean || extractWorkspaceSlug(clean) !== slug) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] dropping persisted tab "${pTab.path}" from ` +
`group "${slug}" — path/slug mismatch`,
);
continue;
}
tabs.push({
id: pTab.id,
path: clean,
title: pTab.title,
icon: pTab.icon,
router: createTabRouter(clean),
historyIndex: 0,
historyLength: 1,
});
}
if (tabs.length === 0) continue;
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
? pGroup.activeTabId
: tabs[0].id;
byWorkspace[slug] = { tabs, activeTabId };
}
// Validate activeTabId — fall back to first tab if stale
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
? persisted.activeTabId
: tabs[0].id;
const activeWorkspaceSlug =
persisted.activeWorkspaceSlug && byWorkspace[persisted.activeWorkspaceSlug]
? persisted.activeWorkspaceSlug
: (Object.keys(byWorkspace)[0] ?? null);
return { ...currentState, tabs, activeTabId };
return { ...currentState, byWorkspace, activeWorkspaceSlug };
},
},
),
);
// ---------------------------------------------------------------------------
// Persisted shapes (for migration)
// ---------------------------------------------------------------------------
interface V1Tab {
id: string;
path: string;
title: string;
icon: string;
}
interface V1Persisted {
tabs: V1Tab[];
activeTabId: string;
}
interface V2PersistedTab {
id: string;
path: string;
title: string;
icon: string;
}
interface V2PersistedGroup {
tabs: V2PersistedTab[];
activeTabId: string;
}
interface V2Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V2PersistedGroup>;
}
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
const byWorkspace: Record<string, V2PersistedGroup> = {};
const oldTabs = v1.tabs ?? [];
for (const tab of oldTabs) {
const slug = extractWorkspaceSlug(tab.path);
if (!slug) continue; // drop root / global-path tabs
if (!byWorkspace[slug]) byWorkspace[slug] = { tabs: [], activeTabId: "" };
byWorkspace[slug].tabs.push({
id: tab.id,
path: tab.path,
title: tab.title,
icon: tab.icon,
});
}
// Each group needs a valid activeTabId. Prefer the one from v1 if it
// landed in this group; otherwise fall back to the first tab.
for (const slug of Object.keys(byWorkspace)) {
const group = byWorkspace[slug];
const hasOldActive = group.tabs.some((t) => t.id === v1.activeTabId);
group.activeTabId = hasOldActive
? (v1.activeTabId as string)
: group.tabs[0].id;
}
// Active workspace: whichever group inherited the v1 activeTab, falling
// back to the first group we created (arbitrary but deterministic given
// Object.keys iteration order on string keys).
let activeWorkspaceSlug: string | null = null;
for (const slug of Object.keys(byWorkspace)) {
if (byWorkspace[slug].activeTabId === v1.activeTabId) {
activeWorkspaceSlug = slug;
break;
}
}
if (!activeWorkspaceSlug) {
activeWorkspaceSlug = Object.keys(byWorkspace)[0] ?? null;
}
return { activeWorkspaceSlug, byWorkspace };
}
// ---------------------------------------------------------------------------
// Selectors (convenience hooks)
// ---------------------------------------------------------------------------
/**
* Pure non-hook helper — useful from event handlers / effects that already
* need `.getState()`. For React subscriptions prefer the stable selectors
* below.
*/
export function getActiveTab(s: TabStore): Tab | null {
if (!s.activeWorkspaceSlug) return null;
const group = s.byWorkspace[s.activeWorkspaceSlug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
}
/**
* The active workspace's tab group, or null when no workspace is active.
*
* Zustand compares selector returns with `Object.is`. Because `updateTab`
* / `updateTabHistory` replace the group object on every router tick
* (immutable update), this selector returns a new reference on every
* router event — that's fine for TabBar which needs to observe tab-list
* changes, but don't use this selector from components that only care
* about one primitive (use `useActiveTabHistory` / `useActiveTabRouter`
* instead).
*/
export function useActiveGroup(): WorkspaceTabGroup | null {
return useTabStore((s) =>
s.activeWorkspaceSlug ? (s.byWorkspace[s.activeWorkspaceSlug] ?? null) : null,
);
}
/**
* Active tab id + active workspace slug as a compact pair. Both primitives
* are stable across unrelated store updates — e.g. an inactive tab's
* router tick doesn't churn these, so consumers don't re-render.
*
* Useful anywhere you'd previously have reached for `useActiveTab()` and
* only needed the identity (for memoization, effect deps, ipc).
*/
export function useActiveTabIdentity(): { slug: string | null; tabId: string | null } {
const slug = useTabStore((s) => s.activeWorkspaceSlug);
const tabId = useTabStore((s) =>
s.activeWorkspaceSlug
? (s.byWorkspace[s.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
);
return { slug, tabId };
}
/**
* Active tab's router — a stable reference across tab updates, because
* routers are created once per tab and never replaced by `updateTab`.
* Subscribers only re-render when the active tab *changes*, not on
* router events within the current tab.
*/
export function useActiveTabRouter(): DataRouter | null {
return useTabStore((s) => getActiveTab(s)?.router ?? null);
}
/**
* History tracking for the active tab as primitives. Subscribers re-render
* only when the numeric index / length change (i.e. on actual navigations),
* not on unrelated store updates.
*/
export function useActiveTabHistory(): {
historyIndex: number;
historyLength: number;
} {
const historyIndex = useTabStore((s) => getActiveTab(s)?.historyIndex ?? 0);
const historyLength = useTabStore((s) => getActiveTab(s)?.historyLength ?? 1);
return { historyIndex, historyLength };
}

View File

@@ -0,0 +1,29 @@
import { create } from "zustand";
/**
* Window-level transition overlay: pre-workspace flows that are NOT pages
* inside a tab. Triggered by navigation-adapter interception, zero-workspace
* auto-redirect, or deep link; rendered above the tab system as a full-window
* takeover.
*
* These flows used to be routes (`/workspaces/new`, `/invite/:id`) but on
* desktop the URL is invisible to users — routes are an implementation detail
* of the tab system. Representing transitions as routes meant tabs tried to
* persist them, TabBar rendered on top, and invite deep-linking had no clean
* dispatch target. Modeling them as application state removes all three.
*/
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string };
interface WindowOverlayStore {
overlay: WindowOverlay | null;
open: (overlay: WindowOverlay) => void;
close: () => void;
}
export const useWindowOverlayStore = create<WindowOverlayStore>((set) => ({
overlay: null,
open: (overlay) => set({ overlay }),
close: () => set({ overlay: null }),
}));

View File

@@ -1,7 +0,0 @@
import type { ReactNode } from "react";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { baseOptions } from "@/app/layout.config";
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
}

View File

@@ -1,29 +0,0 @@
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 text-center px-4">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
Multica Documentation
</h1>
<p className="max-w-2xl text-lg text-fd-muted-foreground">
The open-source managed agents platform. Turn coding agents into real
teammates assign tasks, track progress, compound skills.
</p>
<div className="flex gap-4">
<Link
href="/docs"
className="inline-flex items-center rounded-md bg-fd-primary px-6 py-3 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Get Started
</Link>
<Link
href="https://github.com/multica-ai/multica"
className="inline-flex items-center rounded-md border border-fd-border px-6 py-3 text-sm font-medium transition-colors hover:bg-fd-accent"
>
GitHub
</Link>
</div>
</main>
);
}

View File

@@ -10,7 +10,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
params: Promise<{ slug: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
@@ -29,12 +29,12 @@ export default async function Page(props: {
);
}
export async function generateStaticParams() {
return source.generateParams();
export function generateStaticParams() {
return source.generateParams().filter((p) => p.slug.length > 0);
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
params: Promise<{ slug: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);

View File

@@ -1,12 +0,0 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}

View File

@@ -1,5 +1,4 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { BookOpen, Terminal, Rocket, Code } from "lucide-react";
export const baseOptions: BaseLayoutProps = {
nav: {
@@ -8,11 +7,6 @@ export const baseOptions: BaseLayoutProps = {
),
},
links: [
{
text: "Documentation",
url: "/docs",
active: "nested-url",
},
{
text: "GitHub",
url: "https://github.com/multica-ai/multica",

View File

@@ -1,7 +1,10 @@
import "./global.css";
import { RootProvider } from "fumadocs-ui/provider";
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export const metadata: Metadata = {
title: {
@@ -16,7 +19,11 @@ export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<RootProvider>{children}</RootProvider>
<RootProvider>
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
</RootProvider>
</body>
</html>
);

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export default function NotFound() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 py-24 text-center">
<h1 className="text-3xl font-semibold">Page not found</h1>
<p className="text-fd-muted-foreground">
The page you are looking for doesn&apos;t exist.
</p>
<Link
href="/"
className="inline-flex items-center rounded-md bg-fd-primary px-4 py-2 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Back to docs
</Link>
</main>
);
}

37
apps/docs/app/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { source } from "@/lib/source";
import {
DocsPage,
DocsBody,
DocsDescription,
DocsTitle,
} from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
export default function Page() {
const page = source.getPage([]);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export function generateMetadata(): Metadata {
const page = source.getPage([]);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

@@ -68,7 +68,7 @@ multica setup
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/getting-started/self-hosting) for details.
## Verify

View File

@@ -212,7 +212,7 @@ multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
### Get Issue
@@ -227,7 +227,7 @@ multica issue get <id> --output json
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
### Update Issue
@@ -281,6 +281,70 @@ multica issue run-messages <task-id> --output json
multica issue run-messages <task-id> --since 42 --output json
```
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
### List Projects
```bash
multica project list
multica project list --status in_progress
multica project list --output json
```
Available filters: `--status`.
### Get Project
```bash
multica project get <id>
multica project get <id> --output json
```
### Create Project
```bash
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
### Update Project
```bash
multica project update <id> --title "New title" --status in_progress
multica project update <id> --lead "Lambda"
```
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
### Change Status
```bash
multica project status <id> in_progress
```
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
### Delete Project
```bash
multica project delete <id>
```
### Associating Issues with Projects
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
project, or on `issue list` to filter issues by project:
```bash
multica issue create --title "Login bug" --project <project-id>
multica issue update <issue-id> --project <project-id>
multica issue list --project <project-id>
```
## Configuration
### View Config

View File

@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 log in with any email + code **`888888`**.
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -64,10 +64,14 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
### Step 2 — Log In
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
<Callout>
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
</Callout>
### Step 3 — Install CLI & Start Daemon

View File

@@ -41,7 +41,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
## Next Steps
- [Cloud Quickstart](/docs/getting-started/cloud-quickstart)
- [Self-Hosting](/docs/getting-started/self-hosting)
- [CLI Installation](/docs/cli/installation)
- [Contributing](/docs/developers/contributing)
- [Cloud Quickstart](/getting-started/cloud-quickstart)
- [Self-Hosting](/getting-started/self-hosting)
- [CLI Installation](/cli/installation)
- [Contributing](/developers/contributing)

View File

@@ -2,6 +2,6 @@ import { docs } from "@/.source";
import { loader } from "fumadocs-core/source";
export const source = loader({
baseUrl: "/docs",
baseUrl: "/",
source: docs.toFumadocsSource(),
});

View File

@@ -5,6 +5,7 @@ const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
basePath: "/docs",
};
export default withMDX(config);

View File

@@ -2,8 +2,10 @@
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { InvitePage } from "@multica/views/invite";
export default function InviteAcceptPage() {
@@ -11,6 +13,10 @@ export default function InviteAcceptPage() {
const params = useParams<{ id: string }>();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: wsList = [] } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
// Redirect to login if not authenticated, with a redirect back to this page.
useEffect(() => {
@@ -23,5 +29,8 @@ export default function InviteAcceptPage() {
if (isLoading || !user) return null;
return <InvitePage invitationId={params.id} />;
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return <InvitePage invitationId={params.id} onBack={onBack} />;
}

View File

@@ -11,32 +11,51 @@ function createWrapper() {
);
}
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
const {
mockSendCode,
mockVerifyCode,
mockIssueCliToken,
searchParamsState,
authStateRef,
} = vi.hoisted(() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
mockIssueCliToken: vi.fn(),
searchParamsState: { params: new URLSearchParams() },
authStateRef: {
state: {
sendCode: vi.fn(),
verifyCode: vi.fn(),
user: null as null | { id: string; email: string },
isLoading: false,
},
},
}));
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/login",
useSearchParams: () => new URLSearchParams(),
useSearchParams: () => searchParamsState.params,
}));
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
// web wrapper uses useAuthStore((s) => s.user/isLoading)
vi.mock("@multica/core/auth", () => {
const authState = {
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
user: null,
isLoading: false,
};
// web wrapper uses useAuthStore((s) => s.user/isLoading). Keep the real
// sanitizeNextUrl so the redirect-sanitization rules are exercised rather
// than silently drifting behind a mock reimplementation.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
authStateRef.state.sendCode = mockSendCode;
authStateRef.state.verifyCode = mockVerifyCode;
const useAuthStore = Object.assign(
(selector: (s: typeof authState) => unknown) => selector(authState),
{ getState: () => authState },
(selector: (s: typeof authStateRef.state) => unknown) =>
selector(authStateRef.state),
{ getState: () => authStateRef.state },
);
return { useAuthStore };
return { ...actual, useAuthStore };
});
// Mock auth-cookie
@@ -51,6 +70,7 @@ vi.mock("@multica/core/api", () => ({
verifyCode: vi.fn(),
setToken: vi.fn(),
getMe: vi.fn(),
issueCliToken: mockIssueCliToken,
},
}));
@@ -59,6 +79,9 @@ import LoginPage from "./page";
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
searchParamsState.params = new URLSearchParams();
authStateRef.state.user = null;
authStateRef.state.isLoading = false;
});
it("renders login form with email input and continue button", () => {
@@ -131,4 +154,44 @@ describe("LoginPage", () => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
// Regression: MUL-1080 — if the user is already authenticated on the web
// and the Desktop app redirects them to /login?platform=desktop, the web
// must exchange the cookie session for a bearer token and hand it off via
// the multica:// deep link, not silently redirect to the workspace page.
it("mints a token and deep-links to Desktop when already logged in with platform=desktop", async () => {
searchParamsState.params = new URLSearchParams({ platform: "desktop" });
authStateRef.state.user = { id: "u1", email: "test@multica.ai" };
mockIssueCliToken.mockImplementation(() =>
Promise.resolve({ token: "handoff-jwt" }),
);
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
});
try {
render(<LoginPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(mockIssueCliToken).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(hrefSetter).toHaveBeenCalledWith(
"multica://auth/callback?token=handoff-jwt",
);
});
expect(
await screen.findByRole("button", { name: "Open Multica Desktop" }),
).toBeInTheDocument();
} finally {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
}
});
});

View File

@@ -1,12 +1,22 @@
"use client";
import { Suspense, useEffect } from "react";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { api } from "@multica/core/api";
import type { Workspace } from "@multica/core/types";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
import { Loader2 } from "lucide-react";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
@@ -22,17 +32,39 @@ function LoginPageContent() {
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
const isDesktopHandoff = platform === "desktop" && !cliCallbackRaw;
// `next` carries a protected URL the user was originally headed to
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
// "/issues" default — if `next` is absent we decide after login based on
// the user's workspace list.
const nextUrl = searchParams.get("next");
// the user's workspace list. Sanitize first so a crafted `?next=https://evil`
// cannot bounce the user off-origin after a successful login.
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
const [desktopToken, setDesktopToken] = useState<string | null>(null);
const [desktopError, setDesktopError] = useState("");
// Already authenticated — honor ?next= or fall back to first workspace
// (or /workspaces/new if the user has none). Skip this entire path when
// the user arrived to authorize the CLI.
useEffect(() => {
if (isLoading || !user || cliCallbackRaw) return;
if (isDesktopHandoff) {
// Desktop opened the browser for login but the web session is already
// authenticated — mint a bearer token from the cookie session and hand
// it off via deep link instead of silently redirecting to the workspace.
api
.issueCliToken()
.then(({ token }) => {
setDesktopToken(token);
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
})
.catch((err) => {
setDesktopError(
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
);
});
return;
}
if (nextUrl) {
router.replace(nextUrl);
return;
@@ -42,7 +74,7 @@ function LoginPageContent() {
router.replace(
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, qc]);
const handleSuccess = () => {
if (nextUrl) {
@@ -67,6 +99,52 @@ function LoginPageContent() {
.filter(Boolean)
.join(",") || undefined;
// While the desktop handoff is in progress (or has produced a token/error),
// render a dedicated screen instead of flashing the login form or redirecting
// away to a workspace page.
if (isDesktopHandoff && user) {
if (desktopError) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
<CardDescription>{desktopError}</CardDescription>
</CardHeader>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Opening Multica</CardTitle>
<CardDescription>
{desktopToken
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
: "Preparing Desktop sign-in..."}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
{desktopToken ? (
<Button
variant="outline"
onClick={() => {
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
}}
>
Open Multica Desktop
</Button>
) : (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
)}
</CardContent>
</Card>
</div>
);
}
return (
<LoginPage
onSuccess={handleSuccess}

View File

@@ -2,14 +2,20 @@
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
export default function Page() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: wsList = [] } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (!isLoading && !user) router.replace(paths.login());
@@ -17,9 +23,16 @@ export default function Page() {
if (isLoading || !user) return null;
// Back goes to the root path — the workspace layout redirects from
// there to the user's default workspace. Only show Back when there's
// somewhere to go back to (user already has at least one workspace).
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return (
<NewWorkspacePage
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
);
}

View File

@@ -1,28 +0,0 @@
import { Skeleton } from "@multica/ui/components/ui/skeleton";
export default function DashboardLoading() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
{/* Content skeleton */}
<div className="flex-1 p-4 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 flex-1 max-w-md" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { workspaceBySlugOptions } from "@multica/core/workspace";
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
export default function WorkspaceLayout({
@@ -60,11 +61,17 @@ export default function WorkspaceLayout({
// and we just need to hold null briefly.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
if (isAuthLoading) return null;
const loadingIndicator = (
<div className="flex h-svh items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
);
if (isAuthLoading) return loadingIndicator;
// Don't render children until workspace is resolved. useWorkspaceId()
// throws when the list hasn't populated or the slug is unknown — gating
// here makes that invariant hold for every descendant.
if (!listFetched) return null;
if (!listFetched) return loadingIndicator;
if (!workspace) {
// If we've resolved this slug before in this session, it was just
// removed from our list (deleted/left/evicted). A navigate is almost

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { paths } from "@multica/core/paths";
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
}));
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
// exercised rather than silently diverging from the source of truth.
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
return {
...actual,
useAuthStore: (selector: (s: unknown) => unknown) =>
selector({ loginWithGoogle: mockLoginWithGoogle }),
};
});
vi.mock("@multica/core/workspace/queries", () => ({
workspaceKeys: { list: () => ["workspaces"] },
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockListWorkspaces,
googleLogin: vi.fn(),
},
}));
import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(undefined);
mockListWorkspaces.mockResolvedValue([]);
});
it("falls back to paths.newWorkspace() when no next= is present and the user has no workspace", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
});
});
it("ignores unsafe next= targets from the OAuth state and still lands on the default destination", async () => {
mockSearchParams.set("state", "next:https://evil.example");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
});
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
});
it("honors a safe next= target (e.g. /invite/{id})", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
});
});

View File

@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { api } from "@multica/core/api";
@@ -42,7 +42,9 @@ function CallbackContent() {
const stateParts = state.split(",");
const isDesktop = stateParts.includes("platform:desktop");
const nextPart = stateParts.find((p) => p.startsWith("next:"));
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
// Strip "next:" prefix, then drop anything that isn't a safe relative path
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
const redirectUri = `${window.location.origin}/auth/callback`;

View File

@@ -1,6 +1,5 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -11,8 +10,6 @@ import {
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
githubUrl,
heroButtonClassName,
} from "./shared";
@@ -66,25 +63,14 @@ export function LandingHero() {
</svg>
{t.hero.downloadDesktop}
</Link>
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
>
<GitHubMark className="size-4" />
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex items-center justify-center gap-8">
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<span className="text-[15px] text-white/50">
{t.hero.worksWith}
</span>
<div className="flex items-center gap-6">
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2.5 text-white/80">
<ClaudeCodeLogo className="size-5" />
<span className="text-[15px] font-medium">Claude Code</span>
@@ -117,64 +103,6 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">
@@ -182,7 +110,6 @@ function LandingBackdrop() {
src="/images/landing-bg.jpg"
alt=""
fill
priority
className="object-cover object-center"
/>
</div>
@@ -198,6 +125,7 @@ function ProductImage({ alt }: { alt: string }) {
alt={alt}
width={3532}
height={2382}
priority
className="block h-auto w-full"
sizes="(max-width: 1320px) 100vw, 1320px"
quality={85}

View File

@@ -1,6 +1,8 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
export const en: LandingDict = {
header: {
github: "GitHub",
@@ -120,9 +122,10 @@ export const en: LandingDict = {
headlineFaded: "in the next hour.",
steps: [
{
title: "Sign up & create your workspace",
description:
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
description: ALLOW_SIGNUP
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
},
{
title: "Install the CLI & connect your machine",
@@ -279,6 +282,31 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.5",
date: "2026-04-17",
title: "CLI Autopilot, Cmd+K & Daemon Identity",
changes: [],
features: [
"CLI `autopilot` commands for managing scheduled and triggered automations",
"CLI `issue subscriber` commands for subscription management",
"Cmd+K palette extended — theme toggle, quick new issue/project, copy link, switch workspace",
"Project and sub-issue progress as optional card properties on the issue list",
"Persistent daemon UUID identity — CLI and desktop share one daemon across restarts and machine moves",
"Sole-owner workspace leave preflight check",
"Persist comment collapse state across sessions",
],
fixes: [
"Agents now triggered on comments regardless of issue status",
"Codex sandbox config fixed for macOS network access",
"Editor bubble menu rewritten with @floating-ui/dom for reliable scroll hiding",
"Autopilot creator automatically subscribed to autopilot-created issues",
"Autopilot workspace ID correctly resolved for run-only tasks",
"Desktop restricts `shell.openExternal` to http/https schemes (security)",
"Duplicate agent names return 409 instead of silently failing",
"New tabs in desktop inherit current workspace",
],
},
{
version: "0.2.1",
date: "2026-04-16",

View File

@@ -1,6 +1,8 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
export const zh: LandingDict = {
header: {
github: "GitHub",
@@ -120,9 +122,10 @@ export const zh: LandingDict = {
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
steps: [
{
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
description:
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
description: ALLOW_SIGNUP
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
},
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
@@ -279,6 +282,31 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.2.5",
date: "2026-04-17",
title: "CLI Autopilot、Cmd+K 与 Daemon 身份",
changes: [],
features: [
"CLI `autopilot` 命令,管理定时和触发式自动化",
"CLI `issue subscriber` 订阅管理命令",
"Cmd+K 命令面板扩展——主题切换、快速创建 Issue/项目、复制链接、切换工作区",
"Issue 列表卡片可选显示项目和子 Issue 进度",
"Daemon 持久化 UUID 身份——CLI 和桌面应用共用同一个 daemon跨重启和机器迁移保持一致",
"唯一所有者退出工作区的前置检查",
"评论折叠状态跨会话持久化",
],
fixes: [
"Agent 现在在任意 Issue 状态下都会响应评论触发",
"修复 Codex 沙箱在 macOS 上的网络访问配置",
"编辑器气泡菜单改用 @floating-ui/dom 重写,滚动时正确隐藏",
"Autopilot 创建者自动订阅其生成的 Issue",
"Autopilot run-only 任务正确解析工作区 ID",
"桌面应用 `shell.openExternal` 限制仅允许 http/https 协议(安全)",
"重名 Agent 创建返回 409 而非静默失败",
"桌面应用新建标签页继承当前工作区",
],
},
{
version: "0.2.1",
date: "2026-04-16",

View File

@@ -6,6 +6,7 @@ import { resolve } from "path";
config({ path: resolve(__dirname, "../../.env") });
const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080";
const docsUrl = process.env.DOCS_URL || "http://localhost:4000";
// Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server
// allows cross-origin HMR / webpack requests (e.g. from Tailscale IPs).
@@ -32,24 +33,39 @@ const nextConfig: NextConfig = {
qualities: [75, 80, 85],
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${remoteApiUrl}/api/:path*`,
},
{
source: "/ws",
destination: `${remoteApiUrl}/ws`,
},
{
source: "/auth/:path*",
destination: `${remoteApiUrl}/auth/:path*`,
},
{
source: "/uploads/:path*",
destination: `${remoteApiUrl}/uploads/:path*`,
},
];
return {
// Run before file-system routes so /docs isn't shadowed by the
// [workspaceSlug] dynamic segment.
beforeFiles: [
{
source: "/docs",
destination: `${docsUrl}/docs`,
},
{
source: "/docs/:path*",
destination: `${docsUrl}/docs/:path*`,
},
],
afterFiles: [
{
source: "/api/:path*",
destination: `${remoteApiUrl}/api/:path*`,
},
{
source: "/ws",
destination: `${remoteApiUrl}/ws`,
},
{
source: "/auth/:path*",
destination: `${remoteApiUrl}/auth/:path*`,
},
{
source: "/uploads/:path*",
destination: `${remoteApiUrl}/uploads/:path*`,
},
],
fallback: [],
};
},
};

View File

@@ -21,6 +21,7 @@ services:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}"]
interval: 5s
@@ -55,7 +56,9 @@ services:
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
restart: unless-stopped
frontend:
build:
@@ -71,6 +74,7 @@ services:
- "${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
restart: unless-stopped
volumes:
pgdata:

View File

@@ -0,0 +1,107 @@
# Codex sandbox troubleshooting (macOS `no such host`)
This doc explains the failure mode that caused [MUL-963][mul-963] and the
matrix the daemon now follows when writing Codex's per-task `config.toml`.
[mul-963]: https://multica-api.copilothub.ai/issues/28c34ad2-102a-4f46-91ac-336ed78c5859
## Symptom fingerprint
| Error text | Likely cause |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `dial tcp: lookup HOST: no such host` | **Codex Seatbelt sandbox blocking DNS** (macOS, `workspace-write` mode). |
| `dial tcp IP:PORT: connect: connection refused` | Server/daemon not running on that port (app-level, not sandbox). |
| `dial tcp IP:PORT: i/o timeout` | Container-level network policy or firewall (not Codex sandbox). |
| `x509: certificate signed by unknown authority` | TLS/CA issue, unrelated. |
If you see `no such host` *inside a Codex session on macOS* but `curl https://multica-api.copilothub.ai` from a plain shell on the same machine works, you are hitting the Seatbelt bug below.
## Root cause
Upstream issue: [openai/codex#10390][codex-10390]. On macOS, Codex's Seatbelt
profile for `sandbox_mode = "workspace-write"` silently ignores the
`[sandbox_workspace_write] network_access = true` setting. The seatbelt
policy hard-codes `CODEX_SANDBOX_NETWORK_DISABLED=1`, which blocks DNS/UDP
syscalls. Go's `net.LookupHost` surfaces that as `no such host`.
Linux (Landlock) is **not** affected — only macOS Seatbelt.
[codex-10390]: https://github.com/openai/codex/issues/10390
## What the daemon does now
The daemon writes a *multica-managed* block into each task's
`$CODEX_HOME/config.toml`, delimited by `# BEGIN multica-managed` /
`# END multica-managed` markers. Anything outside the markers is left
untouched so users can still tune Codex behavior.
Decision matrix (see [`server/internal/daemon/execenv/codex_sandbox.go`](../server/internal/daemon/execenv/codex_sandbox.go)):
| Host OS | Codex version | Managed block emits |
| --------- | ------------------------------------------------ | ------------------------------------------------------------------------- |
| non-darwin | any | `sandbox_mode = "workspace-write"` + `sandbox_workspace_write.network_access = true` (dotted-key form) |
| darwin | ≥ `CodexDarwinNetworkAccessFixedVersion` | same as above (upstream fix in effect) |
| darwin | older / unknown (current default) | `sandbox_mode = "danger-full-access"` + warn-level log |
The managed block is always hoisted to the top of `config.toml` and uses
TOML dotted-key syntax rather than a `[sandbox_workspace_write]` section
header. Both are load-bearing: if the block sat after a user table like
`[permissions.multica]`, a bare `sandbox_mode = "..."` line would be parsed
as `permissions.multica.sandbox_mode` and Codex would silently ignore it.
`CodexDarwinNetworkAccessFixedVersion` is an empty string today, meaning *no
known fixed release yet*. Bump it once a tagged Codex release includes the
upstream fix.
When the daemon falls back to `danger-full-access`, it logs at `WARN`:
```
codex sandbox: falling back to danger-full-access on macOS
reason=codex on macOS: seatbelt ignores sandbox_workspace_write.network_access (openai/codex#10390) ...
codex_version=0.121.0
hint=upgrade Codex CLI (e.g. `brew upgrade codex` or `npm i -g @openai/codex`) ...
config_path=/.../codex-home/config.toml
```
## Quick self-check commands
From the host shell (outside the sandbox):
```bash
# Is the Multica API reachable at all?
curl -sSf https://multica-api.copilothub.ai/healthz
```
From inside a Codex session (after the daemon writes its config):
```bash
multica issue list --limit 1 --output json >/dev/null && echo OK
```
If the host curl works but the Codex-session call fails with `no such host`,
the sandbox is the culprit; confirm the daemon picked the right policy by
looking at the managed block in `$CODEX_HOME/config.toml`.
## Options and trade-offs
- **A. Domain-scoped `permissions` profile** (tight): when the upstream
`network_access` fix is available, prefer writing a `permissions.multica`
profile that allows only `multica-api.copilothub.ai` and
`multica-static.copilothub.ai`. Keeps filesystem sandbox intact.
- **B. `danger-full-access`** (current macOS fallback): drops the whole
Seatbelt profile. Simplest reliable workaround until the upstream fix is
released.
- **C. Upgrade Codex CLI**: `brew upgrade codex` or `npm i -g @openai/codex`.
Once a release containing [openai/codex#10390][codex-10390] is installed,
bump `CodexDarwinNetworkAccessFixedVersion` in `codex_sandbox.go` and
option A/the workspace-write path takes over automatically.
## If you need to hand-verify
```bash
# Inspect the managed block the daemon wrote for a given task.
sed -n '/# BEGIN multica-managed/,/# END multica-managed/p' \
~/multica_workspaces/$WORKSPACE_ID/$TASK_SHORT/codex-home/config.toml
```
The block is idempotent — re-running a task rewrites it in place.

View File

@@ -1,5 +1,6 @@
export { createAuthStore } from "./store";
export type { AuthStoreOptions, AuthState } from "./store";
export { sanitizeNextUrl } from "./utils";
import type { createAuthStore as CreateAuthStoreFn } from "./store";

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { sanitizeNextUrl } from "./utils";
describe("sanitizeNextUrl", () => {
it("accepts single-slash relative paths", () => {
expect(sanitizeNextUrl("/issues")).toBe("/issues");
expect(sanitizeNextUrl("/invite/123")).toBe("/invite/123");
expect(sanitizeNextUrl("/issues?tab=assigned#top")).toBe(
"/issues?tab=assigned#top",
);
});
it("returns null for null or empty input", () => {
expect(sanitizeNextUrl(null)).toBeNull();
expect(sanitizeNextUrl("")).toBeNull();
});
it("rejects absolute URLs", () => {
expect(sanitizeNextUrl("https://evil.example")).toBeNull();
expect(sanitizeNextUrl("http://evil.example/path")).toBeNull();
});
it("rejects javascript: and other non-http schemes", () => {
// Caught by the leading-slash rule, but named here so future edits
// to the regex don't silently drop protection against this vector.
expect(sanitizeNextUrl("javascript:alert(1)")).toBeNull();
expect(sanitizeNextUrl("data:text/html,<script>")).toBeNull();
});
it("rejects protocol-relative URLs", () => {
expect(sanitizeNextUrl("//evil.example")).toBeNull();
expect(sanitizeNextUrl("//evil.example/path")).toBeNull();
});
it("rejects paths containing backslashes", () => {
expect(sanitizeNextUrl("/\\evil.example")).toBeNull();
expect(sanitizeNextUrl("\\\\evil.example")).toBeNull();
});
it("rejects paths containing control characters", () => {
expect(sanitizeNextUrl("/safe\u0000bad")).toBeNull();
expect(sanitizeNextUrl("/safe\tbad")).toBeNull();
expect(sanitizeNextUrl("/safe\r\nbad")).toBeNull();
});
});

View File

@@ -0,0 +1,20 @@
/**
* Validate a post-login redirect URL and return it only if safe to follow.
*
* Only single-slash relative paths (e.g. `/invite/abc`) are accepted. Returns
* `null` for unsafe or empty input — call sites decide the fallback so this
* helper never overloads a specific path with "user did not pass next".
*
* Rejects:
* - `null` / empty string
* - absolute URLs (`https://evil.com`, `javascript:alert(1)`, …)
* - protocol-relative URLs (`//evil.com`)
* - paths containing backslashes (Windows-style or `/\\host`)
* - paths containing ASCII control characters (`\x00``\x1f`)
*/
export function sanitizeNextUrl(raw: string | null): string | null {
if (!raw) return null;
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
if (/[\x00-\x1f\\]/.test(raw)) return null;
return raw;
}

View File

@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
import { useWorkspaceId } from "../hooks";
import { useRecentIssuesStore } from "./stores";
import type { Issue, IssueReaction } from "../types";
import type {
CreateIssueRequest,
@@ -94,6 +95,9 @@ export function useCreateIssue() {
}
: old,
);
// Surface the just-created issue in cmd+k's Recent list without
// requiring the user to open it first.
useRecentIssuesStore.getState().recordVisit(newIssue.id);
// Invalidate parent's children query so sub-issues list updates immediately
if (newIssue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });

View File

@@ -18,6 +18,8 @@ export interface CardProperties {
description: boolean;
assignee: boolean;
dueDate: boolean;
project: boolean;
childProgress: boolean;
}
export interface ActorFilterValue {
@@ -38,6 +40,8 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "childProgress", label: "Sub-issue progress" },
];
export interface IssueViewState {
@@ -86,6 +90,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
description: true,
assignee: true,
dueDate: true,
project: true,
childProgress: true,
},
listCollapsedStatuses: [],

View File

@@ -2,7 +2,7 @@
import { create } from "zustand";
type ModalType = "create-workspace" | "create-issue" | null;
type ModalType = "create-workspace" | "create-issue" | "create-project" | null;
interface ModalStore {
modal: ModalType;

View File

@@ -28,6 +28,9 @@ export const RESERVED_SLUGS = new Set([
// Platform / marketing routes (current + likely-future)
"api",
"admin",
"multica", // brand name — prevent impersonation workspaces
"www", // hostname confusable; never a legitimate workspace slug
"new", // ambiguous verb-as-slug; reserved for future global create routes
"help",
"about",
"pricing",

View File

@@ -28,6 +28,11 @@ function runtimeNeedsUpdate(
if (rt.runtime_mode !== "local") return false;
// Only show to the user who owns this runtime.
if (rt.owner_id !== userId) return false;
// Desktop-managed runtimes are updated by the Desktop app's own auto-updater;
// the platform should not surface CLI update prompts for them.
if (rt.metadata && rt.metadata.launched_by === "desktop") {
return false;
}
const cliVersion =
rt.metadata && typeof rt.metadata.cli_version === "string"
? rt.metadata.cli_version

View File

@@ -11,6 +11,7 @@ export interface RuntimeDevice {
name: string;
runtime_mode: AgentRuntimeMode;
provider: string;
launch_header: string;
status: "online" | "offline";
device_info: string;
metadata: Record<string, unknown>;

View File

@@ -178,6 +178,7 @@ export function AgentDetail({
{activeTab === "custom_args" && (
<CustomArgsTab
agent={agent}
runtimeDevice={runtimeDevice}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}

View File

@@ -24,11 +24,10 @@ import { AgentListItem } from "./agent-list-item";
import { AgentDetail } from "./agent-detail";
export function AgentsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const currentUser = useAuthStore((s) => s.user);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: agents = [], isLoading } = useQuery(agentListOptions(wsId));
const [selectedId, setSelectedId] = useState<string>("");
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);

View File

@@ -7,7 +7,8 @@ import {
Plus,
Trash2,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import type { Agent, RuntimeDevice } from "@multica/core/types";
import { createSafeId } from "@multica/core/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
@@ -19,7 +20,7 @@ interface ArgEntry {
}
function argsToEntries(args: string[]): ArgEntry[] {
return args.map((value) => ({ id: crypto.randomUUID(), value }));
return args.map((value) => ({ id: createSafeId(), value }));
}
function entriesToArgs(entries: ArgEntry[]): string[] {
@@ -28,9 +29,11 @@ function entriesToArgs(entries: ArgEntry[]): string[] {
export function CustomArgsTab({
agent,
runtimeDevice,
onSave,
}: {
agent: Agent;
runtimeDevice?: RuntimeDevice;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [entries, setEntries] = useState<ArgEntry[]>(
@@ -43,7 +46,7 @@ export function CustomArgsTab({
const dirty = JSON.stringify(currentArgs) !== JSON.stringify(originalArgs);
const addEntry = () => {
setEntries([...entries, { id: crypto.randomUUID(), value: "" }]);
setEntries([...entries, { id: createSafeId(), value: "" }]);
};
const removeEntry = (index: number) => {
@@ -68,6 +71,8 @@ export function CustomArgsTab({
}
};
const launchHeader = runtimeDevice?.launch_header;
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">
@@ -76,9 +81,17 @@ export function CustomArgsTab({
Custom Arguments
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Additional CLI arguments appended to the agent command at launch
(e.g. --model claude-sonnet-4-20250514)
Additional CLI arguments appended to the agent command at launch.
Supported flags depend on the agent's CLI.
</p>
{launchHeader && (
<p className="mt-2 text-xs text-muted-foreground">
Launch mode:{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{launchHeader} &lt;your args&gt;
</code>
</p>
)}
</div>
<Button
type="button"
@@ -98,7 +111,7 @@ export function CustomArgsTab({
<Input
value={entry.value}
onChange={(e) => updateEntry(index, e.target.value)}
placeholder="--model claude-sonnet-4-20250514"
placeholder="--flag value"
className="flex-1 font-mono text-xs"
/>
<button

View File

@@ -0,0 +1,174 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, AgentTask, Issue } from "@multica/core/types";
const mockListAgentTasks = vi.hoisted(() => vi.fn());
const mockListIssues = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useWorkspacePaths: () => actual.paths.workspace("test"),
};
});
vi.mock("@multica/core/api", () => ({
api: {
listAgentTasks: (...args: unknown[]) => mockListAgentTasks(...args),
listIssues: (...args: unknown[]) => mockListIssues(...args),
},
}));
vi.mock("../../../navigation", () => ({
AppLink: ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
import { TasksTab } from "./tasks-tab";
const agent: Agent = {
id: "agent-1",
workspace_id: "ws-1",
runtime_id: "runtime-1",
name: "Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
owner_id: null,
skills: [],
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
archived_at: null,
archived_by: null,
};
function renderTasksTab(tasks: AgentTask[], issues: Issue[]) {
mockListAgentTasks.mockResolvedValue(tasks);
mockListIssues.mockImplementation(
({ open_only, status }: { open_only?: boolean; status?: string }) =>
Promise.resolve({
issues: open_only ? issues : status === "done" ? [] : [],
total: open_only ? issues.length : 0,
}),
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<TasksTab agent={agent} />
</QueryClientProvider>,
);
}
describe("TasksTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses workspace-scoped issue detail paths when issue data is loaded", async () => {
renderTasksTab(
[
{
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "issue-1",
status: "queued",
priority: 1,
dispatched_at: null,
started_at: null,
completed_at: null,
result: null,
error: null,
created_at: "2026-04-16T00:00:00Z",
},
],
[
{
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Fix agent task routing",
description: "",
status: "todo",
priority: "medium",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 1,
due_date: null,
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
},
],
);
const title = await screen.findByText("Fix agent task routing");
const link = title.closest("a");
expect(link?.getAttribute("href")).toBe("/test/issues/issue-1");
});
it("keeps task rows clickable when the issue is missing from the list query", async () => {
renderTasksTab(
[
{
id: "task-2",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "12345678-fallback",
status: "completed",
priority: 1,
dispatched_at: null,
started_at: null,
completed_at: "2026-04-16T01:00:00Z",
result: null,
error: null,
created_at: "2026-04-16T00:00:00Z",
},
],
[],
);
await waitFor(() => {
expect(mockListAgentTasks).toHaveBeenCalledWith("agent-1");
});
const title = await screen.findByText("Issue 12345678...");
const link = title.closest("a");
expect(link?.getAttribute("href")).toBe("/test/issues/12345678-fallback");
});
});

View File

@@ -6,14 +6,17 @@ import type { Agent, AgentTask } from "@multica/core/types";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { issueListOptions } from "@multica/core/issues/queries";
import { useQuery } from "@tanstack/react-query";
import { AppLink } from "../../../navigation";
import { taskStatusConfig } from "../../config";
export function TasksTab({ agent }: { agent: Agent }) {
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [loading, setLoading] = useState(true);
const wsId = useWorkspaceId();
const paths = useWorkspacePaths();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
useEffect(() => {
@@ -82,18 +85,16 @@ export function TasksTab({ agent }: { agent: Agent }) {
const issue = issueMap.get(task.issue_id);
const isActive = task.status === "running" || task.status === "dispatched";
const isRunning = task.status === "running";
const rowClassName = `flex items-center gap-3 rounded-lg border px-4 py-3 transition-shadow hover:shadow-sm ${
isRunning
? "border-success/40 bg-success/5"
: task.status === "dispatched"
? "border-info/40 bg-info/5"
: ""
}`;
return (
<div
key={task.id}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
isRunning
? "border-success/40 bg-success/5"
: task.status === "dispatched"
? "border-info/40 bg-info/5"
: ""
}`}
>
const content = (
<>
<Icon
className={`h-4 w-4 shrink-0 ${config.color} ${
isRunning ? "animate-spin" : ""
@@ -110,7 +111,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
{issue?.title ?? `Issue ${task.issue_id.slice(0, 8)}...`}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
<div className="mt-0.5 text-xs text-muted-foreground">
{isRunning && task.started_at
? `Started ${new Date(task.started_at).toLocaleString()}`
: task.status === "dispatched" && task.dispatched_at
@@ -125,7 +126,17 @@ export function TasksTab({ agent }: { agent: Agent }) {
<span className={`shrink-0 text-xs font-medium ${config.color}`}>
{config.label}
</span>
</div>
</>
);
return (
<AppLink
key={task.id}
href={paths.issueDetail(task.issue_id)}
className={`${rowClassName} text-foreground no-underline hover:no-underline`}
>
{content}
</AppLink>
);
})}
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
import {
@@ -20,6 +20,7 @@ import { PageHeader } from "../../layout/page-header";
import { ActorAvatar } from "../../common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
@@ -51,9 +52,9 @@ function formatDate(date: string): string {
});
}
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2; spin?: boolean }> = {
issue_created: { label: "Issue Created", color: "text-blue-500", icon: Clock },
running: { label: "Running", color: "text-blue-500", icon: Loader2 },
running: { label: "Running", color: "text-blue-500", icon: Loader2, spin: true },
completed: { label: "Completed", color: "text-emerald-500", icon: CheckCircle2 },
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
@@ -65,7 +66,7 @@ function RunRow({ run }: { run: AutopilotRun }) {
const content = (
<>
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
@@ -384,9 +385,39 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
if (isLoading) {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
<div className="flex h-full flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-5">
<Skeleton className="h-4 w-4" />
<span className="text-muted-foreground">/</span>
<Skeleton className="h-4 w-32" />
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6 space-y-8">
<section className="space-y-4">
<Skeleton className="h-3 w-20" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-32" />
</div>
<div className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-24" />
</div>
</div>
</section>
<section className="space-y-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full rounded-md" />
</section>
<section className="space-y-3">
<Skeleton className="h-4 w-24" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</section>
</div>
</div>
</div>
);
}
@@ -420,9 +451,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
}
};
const handleToggleStatus = () => {
const newStatus = autopilot.status === "active" ? "paused" : "active";
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
const handleToggleStatus = (checked: boolean) => {
updateAutopilot.mutate({ id: autopilotId, status: checked ? "active" : "paused" });
};
return (
@@ -435,27 +465,29 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
</AppLink>
<span className="text-muted-foreground">/</span>
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
<span className={cn(
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
autopilot.status === "active" ? "bg-emerald-500/10 text-emerald-500" :
autopilot.status === "paused" ? "bg-amber-500/10 text-amber-500" :
"bg-muted text-muted-foreground",
)}>
{autopilot.status}
</span>
<div className="ml-1 flex items-center gap-1.5">
<Switch
size="sm"
checked={autopilot.status === "active"}
onCheckedChange={handleToggleStatus}
disabled={autopilot.status === "archived"}
aria-label={autopilot.status === "active" ? "Pause autopilot" : "Activate autopilot"}
/>
<span className={cn(
"text-xs font-medium capitalize",
autopilot.status === "active" ? "text-emerald-500" :
autopilot.status === "paused" ? "text-amber-500" :
"text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
<Pencil className="h-3.5 w-3.5 mr-1" />
Edit
</Button>
<Button size="sm" variant="outline" onClick={handleToggleStatus}>
{autopilot.status === "active" ? (
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
) : (
<><Play className="h-3.5 w-3.5 mr-1" /> Activate</>
)}
</Button>
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
<Play className="h-3.5 w-3.5 mr-1" />
{triggerAutopilot.isPending ? "Running..." : "Run now"}

View File

@@ -366,11 +366,21 @@ export function AutopilotsPage() {
{/* Table */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-5 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
<>
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
<span className="shrink-0 w-4" />
<Skeleton className="h-3 w-12 flex-1 max-w-[48px]" />
<Skeleton className="h-3 w-12 shrink-0" />
<Skeleton className="h-3 w-10 shrink-0" />
<Skeleton className="h-3 w-10 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
</div>
<div className="p-5 pt-1 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
</>
) : autopilots.length === 0 ? (
<div className="flex flex-col items-center py-16 px-5">
<Zap className="h-10 w-10 mb-3 text-muted-foreground opacity-30" />

View File

@@ -15,7 +15,7 @@ export type TriggerFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "cus
export interface TriggerConfig {
frequency: TriggerFrequency;
time: string; // HH:MM
dayOfWeek: number; // 0=Sun … 6=Sat
daysOfWeek: number[]; // 0=Sun … 6=Sat — used when frequency === "weekly"
cronExpression: string; // only used when frequency === "custom"
timezone: string; // IANA
}
@@ -28,7 +28,7 @@ const FREQUENCIES: { value: TriggerFrequency; label: string }[] = [
{ value: "hourly", label: "Hourly" },
{ value: "daily", label: "Daily" },
{ value: "weekdays", label: "Weekdays" },
{ value: "weekly", label: "Weekly" },
{ value: "weekly", label: "Days" },
{ value: "custom", label: "Custom" },
];
@@ -102,12 +102,22 @@ export function getDefaultTriggerConfig(): TriggerConfig {
return {
frequency: "daily",
time: "09:00",
dayOfWeek: 1,
daysOfWeek: [1],
cronExpression: "0 9 * * 1-5",
timezone: getLocalTimezone(),
};
}
function sortedDays(days: number[]): number[] {
return [...new Set(days)].sort((a, b) => a - b);
}
function formatDayList(days: number[]): string {
const sorted = sortedDays(days);
if (sorted.length === 0) return "—";
return sorted.map((d) => DAYS_OF_WEEK[d]).join(", ");
}
export function toCronExpression(cfg: TriggerConfig): string {
const [h, m] = cfg.time.split(":");
const hour = parseInt(h ?? "9", 10);
@@ -119,8 +129,11 @@ export function toCronExpression(cfg: TriggerConfig): string {
return `${min} ${hour} * * *`;
case "weekdays":
return `${min} ${hour} * * 1-5`;
case "weekly":
return `${min} ${hour} * * ${cfg.dayOfWeek}`;
case "weekly": {
const days = sortedDays(cfg.daysOfWeek);
const dow = days.length > 0 ? days.join(",") : "1";
return `${min} ${hour} * * ${dow}`;
}
case "custom":
return cfg.cronExpression;
}
@@ -138,7 +151,7 @@ export function describeTrigger(cfg: TriggerConfig): string {
case "weekdays":
return `Runs weekdays at ${formatTime12h(cfg.time)} ${offset}`;
case "weekly":
return `Runs every ${DAYS_OF_WEEK[cfg.dayOfWeek]} at ${formatTime12h(cfg.time)} ${offset}`;
return `Runs every ${formatDayList(cfg.daysOfWeek)} at ${formatTime12h(cfg.time)} ${offset}`;
case "custom":
return `Custom schedule: ${cfg.cronExpression}`;
}
@@ -251,26 +264,39 @@ export function TriggerConfigSection({
)}
</div>
{/* Day-of-week selector for weekly */}
{/* Day-of-week multi-selector for weekly */}
{config.frequency === "weekly" && (
<div>
<label className="text-xs text-muted-foreground">Day</label>
<label className="text-xs text-muted-foreground">Days</label>
<div className="flex gap-1 mt-1">
{DAYS_OF_WEEK.map((day, i) => (
<button
key={day}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
config.dayOfWeek === i
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => onChange({ ...config, dayOfWeek: i })}
>
{day}
</button>
))}
{DAYS_OF_WEEK.map((day, i) => {
const selected = config.daysOfWeek.includes(i);
return (
<button
key={day}
type="button"
aria-pressed={selected}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
selected
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => {
const next = selected
? config.daysOfWeek.filter((d) => d !== i)
: [...config.daysOfWeek, i];
// Keep at least one day selected so the cron stays valid.
onChange({
...config,
daysOfWeek: next.length > 0 ? next : config.daysOfWeek,
});
}}
>
{day}
</button>
);
})}
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@
import { useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
@@ -86,16 +87,16 @@ export function ChatMessageSkeleton() {
<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" />
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3.5 w-1/2" />
</div>
<div className="flex justify-end">
<div className="h-8 w-48 rounded-2xl bg-muted animate-pulse" />
<Skeleton className="h-8 w-48 rounded-2xl" />
</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" />
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3.5 w-5/6" />
<Skeleton className="h-3.5 w-1/3" />
</div>
</div>
</div>

View File

@@ -34,11 +34,16 @@ export function useChatResize(
if (!parent) return;
const update = () => {
boundsRef.current = {
maxW: Math.floor(parent.clientWidth * MAX_RATIO),
maxH: Math.floor(parent.clientHeight * MAX_RATIO),
};
setBoundsReady(true);
const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
setBoundsReady(true); // idempotent once true
// Only trigger a re-render if the bounds actually changed. Without this
// guard, any spurious ResizeObserver notification (including sub-pixel
// layout jitter during mount) schedules a setState that feeds back into
// the observer, producing "Maximum update depth exceeded".
const prev = boundsRef.current;
if (prev.maxW === maxW && prev.maxH === maxH) return;
boundsRef.current = { maxW, maxH };
setRevision((r) => r + 1);
};

View File

@@ -3,20 +3,37 @@
/**
* EditorBubbleMenu — floating formatting toolbar for text selection.
*
* Uses Tiptap's native <BubbleMenu> component which has battle-tested
* focus management (preventHide flag, relatedTarget checks, mousedown
* capture). We only add scroll-container visibility detection on top,
* because the plugin's hide middleware can't detect nested scroll
* container clipping (virtual element has no contextElement).
* Positioned with @floating-ui/dom (computePosition + autoUpdate) and
* portaled to document.body via createPortal. This escapes ALL overflow
* containers in the ancestor chain (Card overflow:hidden, scrollable
* containers, etc.) while autoUpdate monitors every ancestor scroll
* container to keep the menu anchored to the selection.
*
* Key design decisions:
* - contextElement on the virtual reference tells Floating UI where to
* find scroll ancestors, enabling the hide middleware to detect
* nested scroll container clipping.
* - visibility:hidden (not display:none) keeps the element measurable
* so computePosition can size it correctly on first show.
* - onMouseDown preventDefault on the portal root prevents all clicks
* inside the menu from stealing focus from the editor.
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import {
computePosition,
offset,
flip,
shift,
hide,
autoUpdate,
} from "@floating-ui/dom";
import { useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/core";
import { posToDOMRect } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { toast } from "sonner";
import { useCreateIssue } from "@multica/core/issues/mutations";
import { Toggle } from "@multica/ui/components/ui/toggle";
import { Separator } from "@multica/ui/components/ui/separator";
import {
@@ -49,32 +66,22 @@ import {
Heading1,
Heading2,
Heading3,
FilePlus,
Loader2,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function shouldShowBubbleMenu({
editor,
view,
state,
from,
to,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}) {
function shouldShowBubbleMenu(editor: Editor): boolean {
if (!editor.isEditable) return false;
if (state.selection.empty) return false;
if (!state.doc.textBetween(from, to).trim().length) return false;
if (state.selection instanceof NodeSelection) return false;
if (!view.hasFocus()) return false;
const $from = state.doc.resolve(from);
const { selection } = editor.state;
if (selection.empty) return false;
const { from, to } = selection;
if (!editor.state.doc.textBetween(from, to).trim().length) return false;
if (selection instanceof NodeSelection) return false;
const $from = editor.state.doc.resolve(from);
if ($from.parent.type.name === "codeBlock") return false;
return true;
}
@@ -83,17 +90,6 @@ const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
/** Walk up from `el` to find the nearest ancestor with overflow: auto/scroll. */
function getScrollParent(el: HTMLElement): HTMLElement | Window {
let parent = el.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (/(auto|scroll)/.test(style.overflow + style.overflowY)) return parent;
parent = parent.parentElement;
}
return window;
}
// ---------------------------------------------------------------------------
// Mark Toggle Button
// ---------------------------------------------------------------------------
@@ -353,17 +349,111 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E
}
// ---------------------------------------------------------------------------
// Main Bubble Menu — native Tiptap <BubbleMenu>
// Create Sub-Issue Button
// ---------------------------------------------------------------------------
function EditorBubbleMenu({ editor }: { editor: Editor }) {
/**
* Turns the current selection into a sub-issue of `parentIssueId` and replaces
* the selection with a mention link to the new issue. Title is the selected
* text (trimmed, collapsed whitespace, capped). Only rendered when a parent
* issue is in scope; otherwise there's no meaningful "sub-issue of" target.
*/
function CreateSubIssueButton({
editor,
parentIssueId,
}: {
editor: Editor;
parentIssueId: string;
}) {
const createIssue = useCreateIssue();
const [pending, setPending] = useState(false);
const handleClick = useCallback(async () => {
if (pending) return;
const { from, to } = editor.state.selection;
if (from === to) return;
// Title from selection: collapse whitespace, cap length. The full selection
// still becomes the link text — only the issue title is capped.
const rawTitle = editor.state.doc.textBetween(from, to, " ", " ").trim();
const title = rawTitle.replace(/\s+/g, " ").slice(0, 200);
if (!title) return;
setPending(true);
try {
const newIssue = await createIssue.mutateAsync({
title,
parent_issue_id: parentIssueId,
});
editor
.chain()
.focus()
.insertContentAt(
{ from, to },
[
{
type: "mention",
attrs: {
id: newIssue.id,
label: newIssue.identifier,
type: "issue",
},
},
{ type: "text", text: " " },
],
)
.run();
toast.success(`Created ${newIssue.identifier}`);
} catch {
toast.error("Failed to create sub-issue");
} finally {
setPending(false);
}
}, [editor, parentIssueId, createIssue, pending]);
return (
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={false}
disabled={pending}
onPressedChange={handleClick}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
{pending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<FilePlus className="size-3.5" />
)}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
Create sub-issue from selection
</TooltipContent>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// Main Bubble Menu — @floating-ui/dom + portal to body
// ---------------------------------------------------------------------------
function EditorBubbleMenu({
editor,
currentIssueId,
}: {
editor: Editor;
currentIssueId?: string;
}) {
const [visible, setVisible] = useState(false);
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
const menuElRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
// Precise subscription to formatting state — only re-renders when these
// values actually change, replacing direct editor.isActive() calls that
// relied on the parent re-rendering on every transaction.
// values actually change, not on every transaction.
const fmt = useEditorState({
editor,
selector: ({ editor: e }) => ({
@@ -381,110 +471,106 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
}),
});
// Find the real scroll container once the editor view is ready.
// editor.view.dom throws if the view hasn't been mounted yet or has been
// destroyed — the Proxy only stubs state/isDestroyed, everything else throws.
// This race happens on fast page transitions in Desktop (Inbox switching)
// because useEditor delays destruction via setTimeout(..., 1) for StrictMode
// survival (TipTap issue #7346).
// Virtual reference that tracks the text selection.
// contextElement tells autoUpdate/hide where to find scroll ancestors.
const virtualRef = useMemo(
() => ({
getBoundingClientRect: () => {
if (editor.isDestroyed) return new DOMRect();
const { from, to } = editor.state.selection;
return posToDOMRect(editor.view, from, to);
},
contextElement: editor.view.dom,
}),
[editor],
);
// Show/hide based on selection state
useEffect(() => {
const detect = () => {
if (!editor.isInitialized) return; // view not ready yet
setScrollTarget(getScrollParent(editor.view.dom));
const onTransaction = () => {
if (!editor.isInitialized) return;
setVisible(shouldShowBubbleMenu(editor));
};
detect();
editor.on("create", detect);
return () => { editor.off("create", detect); };
editor.on("transaction", onTransaction);
return () => { editor.off("transaction", onTransaction); };
}, [editor]);
// Hide when the selection scrolls outside the scroll container's
// visible area. The plugin's hide middleware can't detect this because
// its virtual reference element has no contextElement — Floating UI
// only checks viewport bounds. We use `display` (not managed by the
// plugin) as an additive visibility layer.
const scrollHiddenRef = useRef(false);
const [, forceRender] = useState(0);
// Hide on blur — debounced to allow focus to settle (e.g. clicking menu)
useEffect(() => {
if (scrollTarget === window) return;
const el = scrollTarget as HTMLElement;
const onBlur = () => {
setTimeout(() => {
if (editor.isDestroyed) return;
const el = floatingRef.current;
if (el && el.contains(document.activeElement)) return;
if (editor.view.hasFocus()) return;
setVisible(false);
}, 0);
};
editor.on("blur", onBlur);
return () => { editor.off("blur", onBlur); };
}, [editor]);
const onScroll = () => {
if (editor.state.selection.empty) {
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
return;
}
// editor.view.coordsAtPos throws if the view has been destroyed
// during a fast unmount race (same Proxy guard as view.dom above).
let coords: { top: number };
try {
coords = editor.view.coordsAtPos(editor.state.selection.from);
} catch {
return;
}
const rect = el.getBoundingClientRect();
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
if (scrollHiddenRef.current !== !visible) {
scrollHiddenRef.current = !visible;
forceRender((n) => n + 1);
}
// Position the floating element with autoUpdate when visible
useEffect(() => {
const el = floatingRef.current;
if (!visible || !el || !editor.isInitialized) return;
const updatePosition = () => {
computePosition(virtualRef, el, {
strategy: "fixed",
placement: "top",
middleware: [offset(8), flip(), shift({ padding: 8 }), hide()],
}).then(({ x, y, middlewareData }) => {
if (!el.isConnected) return;
const hidden = middlewareData.hide?.referenceHidden;
el.style.visibility = hidden ? "hidden" : "visible";
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [editor, scrollTarget]);
// autoUpdate monitors all scroll ancestors (via contextElement),
// resize, and animation frames — no manual scroll listener needed.
const cleanup = autoUpdate(virtualRef, el, updatePosition);
return cleanup;
}, [visible, editor, virtualRef]);
// Reset scroll-hidden and mode when selection changes
// Close on outside click
useEffect(() => {
const handler = () => {
setMode("toolbar");
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
if (!visible) return;
const handle = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (editor.view.dom.contains(target)) return;
if (floatingRef.current?.contains(target)) return;
setVisible(false);
};
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [visible, editor]);
// Reset mode on selection change
useEffect(() => {
const handler = () => setMode("toolbar");
editor.on("selectionUpdate", handler);
return () => { editor.off("selectionUpdate", handler); };
}, [editor]);
// Refocus editor when Base UI dropdown closes
// Refocus editor when Popover closes
const handleMenuOpenChange = useCallback(
(open: boolean) => { if (!open) editor.commands.focus(); },
[editor],
);
return (
<BubbleMenu
ref={menuElRef}
editor={editor}
shouldShow={shouldShowBubbleMenu}
updateDelay={0}
<div
ref={floatingRef}
style={{
position: "fixed",
zIndex: 50,
display: scrollHiddenRef.current ? "none" : undefined,
}}
options={{
strategy: "fixed",
placement: "top",
offset: 8,
flip: true,
shift: { padding: 8 },
hide: true,
scrollTarget,
// Tiptap's React wrapper initialises the menu element with
// position:absolute, but computePosition (called right after
// show()) needs position:fixed so that getOffsetParent returns
// the viewport instead of a positioned ancestor. Without this,
// the first positioning computes coordinates relative to the
// wrong containing block and the menu flies off-screen.
onShow: () => {
if (menuElRef.current) {
menuElRef.current.style.position = "fixed";
}
},
width: "max-content",
visibility: visible ? "visible" : "hidden",
}}
onMouseDown={(e) => e.preventDefault()}
>
{mode === "link-edit" ? (
<LinkEditBar editor={editor} onClose={() => { setMode("toolbar"); editor.commands.focus(); }} />
@@ -515,10 +601,16 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
</Tooltip>
{currentIssueId && (
<>
<Separator orientation="vertical" className="mx-0.5 h-5" />
<CreateSubIssueButton editor={editor} parentIssueId={currentIssueId} />
</>
)}
</div>
</TooltipProvider>
)}
</BubbleMenu>
</div>
);
}

View File

@@ -75,6 +75,12 @@ interface ContentEditorProps {
showBubbleMenu?: boolean;
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
submitOnEnter?: boolean;
/**
* ID of the issue this editor belongs to. When set, the bubble menu exposes
* a "Create sub-issue from selection" action that parents the new issue
* under this ID and replaces the selection with a mention link.
*/
currentIssueId?: string;
}
interface ContentEditorRef {
@@ -104,6 +110,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onUploadFile,
showBubbleMenu = true,
submitOnEnter = false,
currentIssueId,
},
ref,
) {
@@ -258,7 +265,9 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{editable && showBubbleMenu && <EditorBubbleMenu editor={editor} />}
{editable && showBubbleMenu && (
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
)}
<LinkHoverCard {...hover} />
</div>
);

View File

@@ -0,0 +1,20 @@
import { Extension } from "@tiptap/core";
/**
* Escape → blur the editor. Without this, pressing ESC inside the
* contenteditable does nothing (browsers don't blur contenteditables by
* default), leaving users stuck in the editor with no keyboard escape hatch.
*/
export function createBlurShortcutExtension() {
return Extension.create({
name: "blurShortcut",
addKeyboardShortcuts() {
return {
Escape: ({ editor }) => {
editor.commands.blur();
return true;
},
};
},
});
}

View File

@@ -132,6 +132,18 @@ export async function uploadAndInsertFile(
}
}
/** Deduplicate files from the same paste/drop event.
* macOS/Chrome can put the same file in the FileList twice. */
function dedupFiles(files: FileList): File[] {
const seen = new Set<string>();
return Array.from(files).filter((file) => {
const key = `${file.name}\0${file.size}\0${file.type}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
@@ -143,7 +155,7 @@ export function createFileUploadExtension(
const handleFiles = async (files: FileList) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
for (const file of Array.from(files)) {
for (const file of dedupFiles(files)) {
await uploadAndInsertFile(editor, file, handler);
}
return true;
@@ -170,10 +182,10 @@ export function createFileUploadExtension(
// Only the first file uses the drop position; subsequent files
// append to the end to avoid stale position issues.
const dropPos = view.posAtCoords({ left: dragEvent.clientX, top: dragEvent.clientY });
const fileArray = Array.from(files);
for (let i = 0; i < fileArray.length; i++) {
const unique = dedupFiles(files);
for (let i = 0; i < unique.length; i++) {
const insertPos = i === 0 ? dropPos?.pos : undefined;
uploadAndInsertFile(editor, fileArray[i]!, handler, insertPos);
uploadAndInsertFile(editor, unique[i]!, handler, insertPos);
}
return true;
},

View File

@@ -40,6 +40,7 @@ import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createSubmitExtension } from "./submit-shortcut";
import { createBlurShortcutExtension } from "./blur-shortcut";
import { createFileUploadExtension } from "./file-upload";
import { FileCardExtension } from "./file-card";
import { ImageView } from "./image-view";
@@ -137,6 +138,7 @@ export function createEditorExtensions(
},
{ submitOnEnter: options.submitOnEnter ?? false },
),
createBlurShortcutExtension(),
createFileUploadExtension(options.onUploadFileRef!),
);
}

View File

@@ -61,6 +61,22 @@ export function InboxPage() {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
// Shared inbox links (?issue=<id>) may point to notifications not in this
// user's inbox (archived, or never received). Fall back to the issue page
// so the URL still resolves to something meaningful.
useEffect(() => {
if (loading) return;
if (!selectedKey) return;
if (selected) return;
replace(wsPaths.issueDetail(selectedKey));
}, [loading, selectedKey, selected, replace, wsPaths]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
const inboxPath = wsPaths.inbox();
@@ -68,16 +84,11 @@ export function InboxPage() {
replace(url);
}, [replace, wsPaths]);
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
});
const isMobile = useIsMobile();
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
const markReadMutation = useMarkInboxRead();

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import {
@@ -9,15 +9,31 @@ import {
} from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { useNavigation } from "../navigation";
import { useLogout } from "../auth";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Users, Check, X } from "lucide-react";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { ArrowLeft, LogOut, Users, Check, X } from "lucide-react";
export interface InvitePageProps {
invitationId: string;
/**
* Optional "go back" handler. Caller passes it only when there's a
* sensible destination (user has at least one workspace, or arrived
* from an in-app flow). Omitted on first-invite/zero-workspace paths
* where Back would have nowhere to go — Log out is then the only exit.
*/
onBack?: () => void;
}
export function InvitePage({ invitationId }: InvitePageProps) {
/**
* Full-page shell for the "accept invitation" transition. Shared between
* web (Next.js route `/invite/[id]`) and desktop (window-overlay).
* Top-bar affordances (Back, Log out) live here so both platforms get
* identical UX. Platform chrome (window drag region, immersive mode) is
* layered on by the desktop overlay; web just renders the page directly.
*/
export function InvitePage({ invitationId, onBack }: InvitePageProps) {
const { push } = useNavigation();
const qc = useQueryClient();
const [accepting, setAccepting] = useState(false);
@@ -78,15 +94,22 @@ export function InvitePage({ invitationId }: InvitePageProps) {
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-muted-foreground">Loading invitation...</div>
</div>
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<Skeleton className="h-12 w-12 rounded-full" />
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
<Skeleton className="h-9 w-32 rounded-md" />
</CardContent>
</Card>
</InviteShell>
);
}
if (fetchError || !invitation) {
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
@@ -101,13 +124,13 @@ export function InvitePage({ invitationId }: InvitePageProps) {
</Button>
</CardContent>
</Card>
</div>
</InviteShell>
);
}
if (done === "accepted") {
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
@@ -117,13 +140,13 @@ export function InvitePage({ invitationId }: InvitePageProps) {
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
</CardContent>
</Card>
</div>
</InviteShell>
);
}
if (done === "declined") {
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<h2 className="text-lg font-semibold">Invitation declined</h2>
@@ -133,7 +156,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
</Button>
</CardContent>
</Card>
</div>
</InviteShell>
);
}
@@ -141,7 +164,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-6 py-12">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
@@ -191,6 +214,46 @@ export function InvitePage({ invitationId }: InvitePageProps) {
)}
</CardContent>
</Card>
</InviteShell>
);
}
/**
* Shared chrome for every InvitePage render state (loading, error,
* default, accepted, declined). Keeps Back + Log out buttons in a
* consistent position across all branches and across platforms.
*/
function InviteShell({
onBack,
children,
}: {
onBack?: () => void;
children: ReactNode;
}) {
const logout = useLogout();
return (
<div className="relative flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
{onBack && (
<Button
variant="ghost"
size="sm"
className="absolute top-12 left-12 text-muted-foreground"
onClick={onBack}
>
<ArrowLeft />
Back
</Button>
)}
<Button
variant="ghost"
size="sm"
className="absolute top-12 right-12 text-muted-foreground hover:text-destructive"
onClick={logout}
>
<LogOut />
Log out
</Button>
{children}
</div>
);
}

View File

@@ -8,9 +8,12 @@ import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@multica/core/types";
import { CalendarDays } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { ActorAvatar } from "../../common/actor-avatar";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { projectListOptions } from "@multica/core/projects/queries";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@multica/core/issues/config";
@@ -49,6 +52,12 @@ export const BoardCardContent = memo(function BoardCardContent({
}) {
const storeProperties = useViewStore((s) => s.cardProperties);
const priorityCfg = PRIORITY_CONFIG[issue.priority];
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery({
...projectListOptions(wsId),
enabled: storeProperties.project && !!issue.project_id,
});
const project = issue.project_id ? projects.find((p) => p.id === issue.project_id) : undefined;
const updateIssueMutation = useUpdateIssue();
const handleUpdate = useCallback(
@@ -65,9 +74,11 @@ export const BoardCardContent = memo(function BoardCardContent({
const showDescription = storeProperties.description && issue.description;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showDueDate = storeProperties.dueDate && issue.due_date;
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
return (
<div className="rounded-lg border bg-card p-3.5 shadow-[0_1px_2px_0_rgba(0,0,0,0.03)] transition-shadow group-hover:shadow-sm">
<div className="rounded-lg border-[0.5px] bg-card py-3 px-2.5 shadow-[0_3px_6px_-2px_rgba(0,0,0,0.02),0_1px_1px_0_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-sm">
{/* Row 1: Identifier */}
<p className="text-xs text-muted-foreground">{issue.identifier}</p>
@@ -76,13 +87,23 @@ export const BoardCardContent = memo(function BoardCardContent({
{issue.title}
</p>
{/* Sub-issue progress */}
{childProgress && (
<div className="mt-1.5 inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
<ProgressRing done={childProgress.done} total={childProgress.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress.done}/{childProgress.total}
</span>
{/* Sub-issue progress + project */}
{(showChildProgress || showProject) && (
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
{showChildProgress && (
<div className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
<ProgressRing done={childProgress!.done} total={childProgress!.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress!.done}/{childProgress!.total}
</span>
</div>
)}
{showProject && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5 text-[11px] text-muted-foreground max-w-[160px]">
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>
<span className="truncate">{project!.title}</span>
</span>
)}
</div>
)}

View File

@@ -98,9 +98,24 @@ function DeleteCommentDialog({
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
if (!attachments?.length) return null;
// Skip attachments whose URL is already referenced in the markdown content
// Skip attachments whose URL is already referenced in the markdown content,
// and duplicates of the same file (same name/type/size) that are referenced.
const standalone = content
? attachments.filter((a) => !content.includes(a.url))
? attachments.filter((a) => {
if (content.includes(a.url)) return false;
// Dedup: if another attachment with the same file identity is already
// inline in the content, this is a duplicate upload — skip it.
const hasSiblingInContent = attachments.some(
(other) =>
other.id !== a.id &&
other.filename === a.filename &&
other.content_type === a.content_type &&
other.size_bytes === a.size_bytes &&
content.includes(other.url),
);
if (hasSiblingInContent) return false;
return true;
})
: attachments;
if (!standalone.length) return null;
@@ -276,6 +291,7 @@ function CommentRow({
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="flex items-center justify-between mt-2">
@@ -496,6 +512,7 @@ function CommentCard({
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="flex items-center justify-between mt-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useRef, useState, useCallback } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
@@ -17,29 +17,34 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const uploadMapRef = useRef<Map<string, string>>(new Map());
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
const handleUpload = async (file: File) => {
const handleUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
uploadMapRef.current.set(result.link, result.id);
}
return result;
};
}, [uploadWithToast, issueId]);
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds: string[] = [];
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
setSubmitting(true);
try {
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setAttachmentIds([]);
uploadMapRef.current.clear();
} finally {
setSubmitting(false);
}
@@ -58,6 +63,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">

View File

@@ -386,17 +386,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, loading: timelineLoading, submitComment, submitReply,
timeline, submitComment, submitReply,
editComment, deleteComment, toggleReaction: handleToggleReaction,
} = useIssueTimeline(id, user?.id);
const {
reactions: issueReactions, loading: reactionsLoading,
reactions: issueReactions,
toggleReaction: handleToggleIssueReaction,
} = useIssueReactions(id, user?.id);
const {
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id);
// Token usage
@@ -499,45 +499,44 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-1 min-h-0">
{/* Content skeleton */}
<div className="flex-1 p-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
</div>
</div>
</div>
{/* Sidebar skeleton */}
<div className="w-64 border-l p-4 space-y-4">
<div className="hidden md:block w-80 border-l p-4 space-y-5">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-3 w-16 shrink-0" />
<Skeleton className="h-5 w-24" />
</div>
))}
<Skeleton className="h-px w-full" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-3 w-16 shrink-0" />
<Skeleton className="h-4 w-28" />
</div>
))}
@@ -698,9 +697,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="shrink-0">
<span className="shrink-0 text-muted-foreground">
{issue.identifier}
</span>
<span className="truncate font-medium text-foreground">
{issue.title}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Tooltip>
@@ -1047,22 +1049,16 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
onUpdate={(md) => handleUpdateField({ description: md })}
onUploadFile={handleDescriptionUpload}
debounceMs={1500}
currentIssueId={id}
/>
<div className="flex items-center gap-1 mt-3">
{reactionsLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-7 w-14 rounded-full" />
<Skeleton className="h-7 w-14 rounded-full" />
</div>
) : (
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
getActorName={getActorName}
/>
)}
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
getActorName={getActorName}
/>
<FileUploadButton
size="sm"
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
@@ -1196,15 +1192,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<h2 className="text-base font-semibold">Activity</h2>
</div>
<div className="flex items-center gap-2">
{subscribersLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-4 w-16" />
<div className="flex -space-x-1">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
</div>
) : (<>
<button
onClick={handleToggleSubscribe}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -1282,7 +1269,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</Command>
</PopoverContent>
</Popover>
</>)}
</div>
</div>
@@ -1297,19 +1283,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Timeline entries */}
<div className="mt-4 flex flex-col gap-3">
{timelineLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 px-4">
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
))}
</div>
) : (() => {
{(() => {
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
const repliesByParent = new Map<string, TimelineEntry[]>();
for (const e of timeline) {

View File

@@ -108,7 +108,7 @@ const mockViewState = {
includeNoProject: false,
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true },
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true },
listCollapsedStatuses: [] as string[],
setViewMode: vi.fn(),
toggleStatusFilter: vi.fn(),
@@ -152,6 +152,8 @@ vi.mock("@multica/core/issues/stores/view-store", () => ({
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "childProgress", label: "Sub-issue progress" },
],
}));

View File

@@ -103,19 +103,35 @@ export function IssuesPage() {
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-14 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-16 rounded-md" />
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</div>
{viewMode === "list" ? (
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : (
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,11 +1,15 @@
"use client";
import { memo } from "react";
import { useQuery } from "@tanstack/react-query";
import { AppLink } from "../../navigation";
import type { Issue } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { projectListOptions } from "@multica/core/projects/queries";
import { PriorityIcon } from "./priority-icon";
import { ProgressRing } from "./progress-ring";
@@ -31,6 +35,18 @@ export const ListRow = memo(function ListRow({
const selected = useIssueSelectionStore((s) => s.selectedIds.has(issue.id));
const toggle = useIssueSelectionStore((s) => s.toggle);
const p = useWorkspacePaths();
const storeProperties = useViewStore((s) => s.cardProperties);
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery({
...projectListOptions(wsId),
enabled: storeProperties.project && !!issue.project_id,
});
const project = issue.project_id ? projects.find((pr) => pr.id === issue.project_id) : undefined;
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showDueDate = storeProperties.dueDate && issue.due_date;
return (
<div
@@ -61,24 +77,30 @@ export const ListRow = memo(function ListRow({
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate">{issue.title}</span>
{childProgress && (
{showChildProgress && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5">
<ProgressRing done={childProgress.done} total={childProgress.total} size={14} />
<ProgressRing done={childProgress!.done} total={childProgress!.total} size={14} />
<span className="text-[11px] text-muted-foreground tabular-nums font-medium">
{childProgress.done}/{childProgress.total}
{childProgress!.done}/{childProgress!.total}
</span>
</span>
)}
</span>
{issue.due_date && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.due_date)}
{showProject && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground max-w-[140px]">
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>
<span className="truncate">{project!.title}</span>
</span>
)}
{issue.assignee_type && issue.assignee_id && (
{showDueDate && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.due_date!)}
</span>
)}
{showAssignee && (
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={20}
/>
)}

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useRef, useState, useEffect, useCallback } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
@@ -39,7 +39,7 @@ function ReplyInput({
const [isEmpty, setIsEmpty] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const uploadMapRef = useRef<Map<string, string>>(new Map());
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
@@ -56,23 +56,28 @@ function ReplyInput({
return () => observer.disconnect();
}, []);
const handleUpload = async (file: File) => {
const handleUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
uploadMapRef.current.set(result.link, result.id);
}
return result;
};
}, [uploadWithToast, issueId]);
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds: string[] = [];
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
setSubmitting(true);
try {
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setAttachmentIds([]);
uploadMapRef.current.clear();
} finally {
setSubmitting(false);
}
@@ -105,6 +110,7 @@ function ReplyInput({
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
currentIssueId={issueId}
/>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@multica/ui/lib/utils";
import { AppLink, useNavigation } from "../navigation";
import {
@@ -78,6 +78,16 @@ import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
import { useLogout } from "../auth";
// Stable empty arrays for query defaults. Using an inline `= []` default on
// `useQuery` creates a new array reference on every render when `data` is
// undefined (e.g. query disabled or loading) — which in turn breaks any
// `useEffect`/`useMemo` that depends on the value, and can trigger infinite
// re-render loops when the effect itself calls `setState`.
const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
// Only parameterless paths are valid nav destinations.
@@ -139,7 +149,7 @@ function SortablePinItem({ pin, href, pathname, onUnpin }: { pin: PinnedItem; hr
<SidebarMenuButton
size="sm"
isActive={isActive}
render={<AppLink href={href} />}
render={<AppLink href={href} draggable={false} />}
onClick={(event) => {
if (wasDragged.current) {
wasDragged.current = false;
@@ -147,7 +157,10 @@ function SortablePinItem({ pin, href, pathname, onUnpin }: { pin: PinnedItem; hr
return;
}
}}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
className={cn(
"text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground",
isDragging && "pointer-events-none",
)}
>
{pin.item_type === "issue" && pin.status ? (
/* Override parent [&_svg]:size-4 — pinned items need smaller icons to match sm size */
@@ -199,11 +212,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
const logout = useLogout();
const workspace = useCurrentWorkspace();
const p = useWorkspacePaths();
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const { data: myInvitations = [] } = useQuery(myInvitationListOptions());
const { data: workspaces = EMPTY_WORKSPACES } = useQuery(workspaceListOptions());
const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions());
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
const { data: inboxItems = EMPTY_INBOX } = useQuery({
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
queryFn: () => api.listInbox(),
enabled: !!wsId,
@@ -213,24 +226,42 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = [] } = useQuery({
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
enabled: !!wsId && !!userId,
});
const deletePin = useDeletePin();
const reorderPins = useReorderPins();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
// Local presentational copy of pinnedItems for drop-animation stability.
// Follows TQ at rest; frozen during a drag gesture so a mid-drag cache
// write (our own optimistic update, or a WS refetch) cannot reorder the
// DOM under dnd-kit while its drop animation is still interpolating.
const [localPinned, setLocalPinned] = useState<PinnedItem[]>(pinnedItems);
const isDraggingRef = useRef(false);
useEffect(() => {
if (!isDraggingRef.current) {
setLocalPinned(pinnedItems);
}
}, [pinnedItems]);
const handleDragStart = useCallback(() => {
isDraggingRef.current = true;
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
isDraggingRef.current = false;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = pinnedItems.findIndex((p) => p.id === active.id);
const newIndex = pinnedItems.findIndex((p) => p.id === over.id);
const oldIndex = localPinned.findIndex((p) => p.id === active.id);
const newIndex = localPinned.findIndex((p) => p.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(pinnedItems, oldIndex, newIndex);
const reordered = arrayMove(localPinned, oldIndex, newIndex);
setLocalPinned(reordered);
reorderPins.mutate(reordered);
},
[pinnedItems, reorderPins],
[localPinned, reorderPins],
);
const queryClient = useQueryClient();
@@ -445,7 +476,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</SidebarGroupContent>
</SidebarGroup>
{pinnedItems.length > 0 && (
{localPinned.length > 0 && (
<Collapsible defaultOpen>
<SidebarGroup className="group/pinned">
<SidebarGroupLabel
@@ -454,14 +485,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
>
<span>Pinned</span>
<ChevronRight className="!size-3 ml-1 stroke-[2.5] transition-transform duration-200 group-data-[panel-open]/trigger:rotate-90" />
<span className="ml-auto text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/pinned:opacity-100">{pinnedItems.length}</span>
<span className="ml-auto text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/pinned:opacity-100">{localPinned.length}</span>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={pinnedItems.map((p) => p.id)} strategy={verticalListSortingStrategy}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<SortableContext items={localPinned.map((p) => p.id)} strategy={verticalListSortingStrategy}>
<SidebarMenu className="gap-0.5">
{pinnedItems.map((pin: PinnedItem) => (
{localPinned.map((pin: PinnedItem) => (
<SortablePinItem
key={pin.id}
pin={pin}

View File

@@ -0,0 +1,352 @@
"use client";
import { useState, useRef } from "react";
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useCreateProject } from "@multica/core/projects/mutations";
import {
PROJECT_STATUS_CONFIG,
PROJECT_STATUS_ORDER,
PROJECT_PRIORITY_CONFIG,
PROJECT_PRIORITY_ORDER,
} from "@multica/core/projects/config";
import { useWorkspaceId } from "@multica/core/hooks";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useActorName } from "@multica/core/workspace/hooks";
import type { ProjectStatus, ProjectPriority } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor";
import { PriorityIcon } from "../issues/components/priority-icon";
import { ActorAvatar } from "../common/actor-avatar";
import { useNavigation } from "../navigation";
function PillButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
className,
)}
{...props}
>
{children}
</button>
);
}
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const router = useNavigation();
const workspace = useCurrentWorkspace();
const workspaceName = workspace?.name;
const wsPaths = useWorkspacePaths();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const [title, setTitle] = useState("");
const descEditorRef = useRef<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
const leadQuery = leadFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
const filteredAgents = agents.filter(
(a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery),
);
const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : "Lead";
const createProject = useCreateProject();
const handleSubmit = async () => {
if (!title.trim() || submitting) return;
setSubmitting(true);
try {
const project = await createProject.mutateAsync({
title: title.trim(),
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
icon,
status,
priority,
lead_type: leadType,
lead_id: leadId,
});
onClose();
toast.success("Project created");
router.push(wsPaths.projectDetail(project.id));
} catch {
toast.error("Failed to create project");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
"!transition-all !duration-300 !ease-out",
isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
)}
>
<DialogTitle className="sr-only">New Project</DialogTitle>
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">New project</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
<div className="px-5 pb-2 shrink-0">
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
<PopoverTrigger
render={
<button
type="button"
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
title="Choose icon"
>
{icon || "📁"}
</button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
<EmojiPicker
onSelect={(emoji) => {
setIcon(emoji);
setIconPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
<TitleEditor
autoFocus
defaultValue=""
placeholder="Project title"
className="text-lg font-semibold"
onChange={(v) => setTitle(v)}
onSubmit={handleSubmit}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue=""
placeholder="Add description..."
debounceMs={500}
/>
</div>
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((pr) => (
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
<PriorityIcon priority={pr} />
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Popover
open={leadOpen}
onOpenChange={(v) => {
setLeadOpen(v);
if (!v) setLeadFilter("");
}}
>
<PopoverTrigger
render={
<PillButton>
{leadType && leadId ? (
<>
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
<span>{leadLabel}</span>
</>
) : (
<span className="text-muted-foreground">Lead</span>
)}
</PillButton>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={leadFilter}
onChange={(e) => setLeadFilter(e.target.value)}
placeholder="Assign lead..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => {
setLeadType(undefined);
setLeadId(undefined);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">No lead</span>
</button>
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Members
</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => {
setLeadType("member");
setLeadId(m.user_id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
<span>{m.name}</span>
</button>
))}
</>
)}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agents
</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => {
setLeadType("agent");
setLeadId(a.id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 &&
filteredAgents.length === 0 &&
leadFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
No results
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -3,6 +3,7 @@
import { useModalStore } from "@multica/core/modals";
import { CreateWorkspaceModal } from "./create-workspace";
import { CreateIssueModal } from "./create-issue";
import { CreateProjectModal } from "./create-project";
export function ModalRegistry() {
const modal = useModalStore((s) => s.modal);
@@ -14,6 +15,8 @@ export function ModalRegistry() {
return <CreateWorkspaceModal onClose={close} />;
case "create-issue":
return <CreateIssueModal onClose={close} data={data} />;
case "create-project":
return <CreateProjectModal onClose={close} />;
default:
return null;
}

View File

@@ -129,19 +129,35 @@ export function MyIssuesPage() {
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-14 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-16 rounded-md" />
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</div>
{viewMode === "list" ? (
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : (
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -50,7 +50,6 @@
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@tiptap/core": "^3.22.1",
"@tiptap/extension-bubble-menu": "^3.22.1",
"@tiptap/extension-code-block-lowlight": "^3.22.1",
"@tiptap/extension-document": "^3.22.1",
"@tiptap/extension-image": "^3.22.1",

View File

@@ -1,26 +1,26 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus, Check } from "lucide-react";
import { useState, useCallback } from "react";
import { Plus, FolderKanban, UserMinus, Check } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { projectListOptions } from "@multica/core/projects/queries";
import { useCreateProject, useUpdateProject } from "@multica/core/projects/mutations";
import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITY_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config";
import { useUpdateProject } from "@multica/core/projects/mutations";
import {
PROJECT_STATUS_CONFIG,
PROJECT_STATUS_ORDER,
PROJECT_PRIORITY_CONFIG,
PROJECT_PRIORITY_ORDER,
} from "@multica/core/projects/config";
import { useWorkspaceId } from "@multica/core/hooks";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { useWorkspacePaths } from "@multica/core/paths";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { AppLink, useNavigation } from "../../navigation";
import { useModalStore } from "@multica/core/modals";
import { AppLink } from "../../navigation";
import { ActorAvatar } from "../../common/actor-avatar";
import { useActorName } from "@multica/core/workspace/hooks";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -33,9 +33,6 @@ import {
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { TitleEditor } from "../../editor";
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types";
import { PageHeader } from "../../layout/page-header";
import { PriorityIcon } from "../../issues/components/priority-icon";
@@ -229,316 +226,11 @@ function ProjectRow({ project }: { project: Project }) {
);
}
function PillButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
className,
)}
{...props}
>
{children}
</button>
);
}
function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const router = useNavigation();
const workspace = useCurrentWorkspace();
const workspaceName = workspace?.name;
const wsPaths = useWorkspacePaths();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const [title, setTitle] = useState("");
const descEditorRef = useRef<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Lead popover
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
const leadQuery = leadFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
const leadLabel =
leadType && leadId ? getActorName(leadType, leadId) : "Lead";
const createProject = useCreateProject();
const handleSubmit = async () => {
if (!title.trim() || submitting) return;
setSubmitting(true);
try {
const project = await createProject.mutateAsync({
title: title.trim(),
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
icon,
status,
priority,
lead_type: leadType,
lead_id: leadId,
});
onOpenChange(false);
setTitle("");
setIcon(undefined);
setStatus("planned");
setPriority("none");
setLeadType(undefined);
setLeadId(undefined);
toast.success("Project created");
router.push(wsPaths.projectDetail(project.id));
} catch {
toast.error("Failed to create project");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
"!transition-all !duration-300 !ease-out",
isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
)}
>
<DialogTitle className="sr-only">New Project</DialogTitle>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">New project</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => onOpenChange(false)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
{/* Icon + Title */}
<div className="px-5 pb-2 shrink-0">
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
<PopoverTrigger
render={
<button
type="button"
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
title="Choose icon"
>
{icon || "📁"}
</button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
<EmojiPicker
onSelect={(emoji) => {
setIcon(emoji);
setIconPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
<TitleEditor
autoFocus
defaultValue=""
placeholder="Project title"
className="text-lg font-semibold"
onChange={(v) => setTitle(v)}
onSubmit={handleSubmit}
/>
</div>
{/* Description */}
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue=""
placeholder="Add description..."
debounceMs={500}
/>
</div>
{/* Property toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
{/* Status */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Priority */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => setPriority(p)}>
<PriorityIcon priority={p} />
<span>{PROJECT_PRIORITY_CONFIG[p].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Lead */}
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
<PopoverTrigger
render={
<PillButton>
{leadType && leadId ? (
<>
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
<span>{leadLabel}</span>
</>
) : (
<span className="text-muted-foreground">Lead</span>
)}
</PillButton>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={leadFilter}
onChange={(e) => setLeadFilter(e.target.value)}
placeholder="Assign lead..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => { setLeadType(undefined); setLeadId(undefined); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">No lead</span>
</button>
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => { setLeadType("member"); setLeadId(m.user_id); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
<span>{m.name}</span>
</button>
))}
</>
)}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => { setLeadType("agent"); setLeadId(a.id); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export function ProjectsPage() {
const wsId = useWorkspaceId();
const { data: projects = [], isLoading } = useQuery(projectListOptions(wsId));
const [createOpen, setCreateOpen] = useState(false);
const openCreateProject = () => useModalStore.getState().open("create-project");
return (
<div className="flex h-full flex-col">
@@ -551,7 +243,7 @@ export function ProjectsPage() {
<span className="text-xs text-muted-foreground tabular-nums">{projects.length}</span>
)}
</div>
<Button size="sm" variant="outline" onClick={() => setCreateOpen(true)}>
<Button size="sm" variant="outline" onClick={openCreateProject}>
<Plus className="h-3.5 w-3.5 mr-1" />
New project
</Button>
@@ -560,16 +252,27 @@ export function ProjectsPage() {
{/* Table */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-5 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
<>
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
<span className="shrink-0 w-[24px]" />
<Skeleton className="h-3 w-12 flex-1 max-w-[48px]" />
<Skeleton className="h-3 w-12 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
<Skeleton className="h-3 w-8 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
</div>
<div className="p-5 pt-1 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
</>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<FolderKanban className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">No projects yet</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => setCreateOpen(true)}>
<Button size="sm" variant="outline" className="mt-3" onClick={openCreateProject}>
Create your first project
</Button>
</div>
@@ -593,8 +296,6 @@ export function ProjectsPage() {
</>
)}
</div>
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -5,12 +5,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { SearchCommand } from "./search-command";
import { useSearchStore } from "./search-store";
const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({
const {
mockPush,
mockSearchIssues,
mockSearchProjects,
mockRecentItems,
mockAllIssues,
mockSetTheme,
mockTheme,
mockPathname,
mockGetShareableUrl,
mockWorkspaces,
mockCurrentWorkspace,
mockOpenModal,
mockToastSuccess,
mockClipboardWrite,
} = 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>> },
mockSetTheme: vi.fn(),
mockTheme: { current: "system" as "light" | "dark" | "system" },
mockPathname: { current: "/ws-test/issues" as string },
mockGetShareableUrl: vi.fn((p: string) => `https://app.multica/${p}`),
mockWorkspaces: {
current: [] as Array<{ id: string; name: string; slug: string }>,
},
mockCurrentWorkspace: {
current: null as { id: string; name: string; slug: string } | null,
},
mockOpenModal: vi.fn(),
mockToastSuccess: vi.fn(),
mockClipboardWrite: vi.fn(() => Promise.resolve()),
}));
vi.mock("@multica/core/api", () => ({
@@ -32,6 +60,12 @@ vi.mock("@multica/core", () => ({
}));
vi.mock("@multica/core/paths", () => ({
paths: {
workspace: (slug: string) => ({
issues: () => `/${slug}/issues`,
}),
},
useCurrentWorkspace: () => mockCurrentWorkspace.current,
useWorkspacePaths: () => ({
inbox: () => "/ws-test/inbox",
myIssues: () => "/ws-test/my-issues",
@@ -50,16 +84,40 @@ vi.mock("@multica/core/issues/queries", () => ({
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
}));
vi.mock("@multica/core/workspace/queries", () => ({
workspaceListOptions: () => ({ queryKey: ["workspaces", "list"], enabled: false }),
}));
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(vi.fn(), {
getState: () => ({ open: mockOpenModal }),
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockAllIssues.current }),
useQuery: (opts: { queryKey: readonly unknown[] }) => {
const key = opts.queryKey;
if (key[0] === "workspaces") return { data: mockWorkspaces.current };
return { data: mockAllIssues.current };
},
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: mockPush,
pathname: mockPathname.current,
getShareableUrl: mockGetShareableUrl,
}),
}));
vi.mock("@multica/ui/components/common/theme-provider", () => ({
useTheme: () => ({ theme: mockTheme.current, setTheme: mockSetTheme }),
}));
vi.mock("sonner", () => ({
toast: { success: mockToastSuccess, error: vi.fn() },
}));
describe("SearchCommand", () => {
beforeEach(() => {
mockPush.mockReset();
@@ -67,6 +125,15 @@ describe("SearchCommand", () => {
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
mockRecentItems.current = [];
mockAllIssues.current = [];
mockSetTheme.mockReset();
mockTheme.current = "system";
mockPathname.current = "/ws-test/issues";
mockGetShareableUrl.mockReset().mockImplementation((p: string) => `https://app.multica/${p}`);
mockWorkspaces.current = [];
mockCurrentWorkspace.current = null;
mockOpenModal.mockReset();
mockToastSuccess.mockReset();
mockClipboardWrite.mockReset().mockResolvedValue(undefined);
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
Element.prototype.scrollIntoView = vi.fn();
@@ -94,10 +161,21 @@ describe("SearchCommand", () => {
expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument();
});
it("does not show pages when no query is entered", () => {
it("shows only New Issue by default and hides Pages / Switch Workspace / low-frequency commands until query", () => {
render(<SearchCommand />);
expect(screen.queryByText("Pages")).not.toBeInTheDocument();
expect(screen.queryByText("Switch Workspace")).not.toBeInTheDocument();
// Only the primary creation action surfaces on empty query; everything
// else (theme, copy, New Project) must be revealed by typing.
expect(screen.getByText("Commands")).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"),
).toBeInTheDocument();
expect(screen.queryByText("New Project")).not.toBeInTheDocument();
expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument();
expect(screen.queryByText("Switch to Dark Theme")).not.toBeInTheDocument();
expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument();
});
it("filters navigation pages by query", async () => {
@@ -112,7 +190,6 @@ describe("SearchCommand", () => {
expect(screen.getByText((_, el) => el?.textContent === "Settings" && el?.tagName === "SPAN")).toBeInTheDocument();
});
expect(screen.queryByText("Inbox")).not.toBeInTheDocument();
expect(screen.queryByText("Projects")).not.toBeInTheDocument();
});
it("navigates to page on selection", async () => {
@@ -148,6 +225,198 @@ describe("SearchCommand", () => {
expect(screen.getByText("MUL-2")).toBeInTheDocument();
});
it("shows New Issue / New Project under Commands and triggers the modal store", async () => {
const user = userEvent.setup();
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "new");
await waitFor(() => {
expect(screen.getByText("Commands")).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"),
).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"),
).toBeInTheDocument();
});
const newIssue = await screen.findByText(
(_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN",
);
await user.click(newIssue);
expect(mockOpenModal).toHaveBeenCalledWith("create-issue");
expect(useSearchStore.getState().open).toBe(false);
});
it("hides copy-link commands when not on an issue detail route", async () => {
const user = userEvent.setup();
mockPathname.current = "/ws-test/projects";
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "copy");
// Commands section may still be empty / absent.
expect(screen.queryByText("Copy Issue Link")).not.toBeInTheDocument();
});
it("copies issue link and identifier when on an issue detail route", async () => {
const user = userEvent.setup();
// userEvent.setup() installs its own navigator.clipboard; spy on it so we
// intercept the writeText call without clobbering userEvent's internals.
const writeSpy = vi
.spyOn(navigator.clipboard, "writeText")
.mockImplementation(mockClipboardWrite);
mockPathname.current = "/ws-test/issues/issue-1";
mockAllIssues.current = [
{ id: "issue-1", identifier: "MUL-42", title: "Demo", status: "todo" },
];
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "copy");
const linkItem = await screen.findByText(
(_, el) => el?.textContent === "Copy Issue Link" && el?.tagName === "SPAN",
);
await user.click(linkItem);
expect(mockGetShareableUrl).toHaveBeenCalledWith("/ws-test/issues/issue-1");
expect(mockClipboardWrite).toHaveBeenCalledWith("https://app.multica//ws-test/issues/issue-1");
expect(mockToastSuccess).toHaveBeenCalledWith("Link copied");
// Reopen palette and test identifier copy
act(() => {
useSearchStore.setState({ open: true });
});
const input2 = screen.getByPlaceholderText("Type a command or search...");
await user.type(input2, "copy");
const idItem = await screen.findByText(
(_, el) =>
el?.textContent === "Copy Identifier (MUL-42)" && el?.tagName === "SPAN",
);
await user.click(idItem);
expect(mockClipboardWrite).toHaveBeenCalledWith("MUL-42");
expect(mockToastSuccess).toHaveBeenCalledWith("Copied MUL-42");
writeSpy.mockRestore();
});
it("filters theme commands by query keywords", async () => {
const user = userEvent.setup();
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "dark");
await waitFor(() => {
expect(screen.getByText("Commands")).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"),
).toBeInTheDocument();
});
expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument();
expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument();
});
it("applies the selected theme and closes the palette", async () => {
const user = userEvent.setup();
mockTheme.current = "light";
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "dark");
const darkItem = await screen.findByText(
(_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN",
);
await user.click(darkItem);
expect(mockSetTheme).toHaveBeenCalledWith("dark");
expect(useSearchStore.getState().open).toBe(false);
});
it("matches theme action via generic 'theme' keyword and marks current theme", async () => {
const user = userEvent.setup();
mockTheme.current = "dark";
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "theme");
await waitFor(() => {
expect(
screen.getByText((_, el) => el?.textContent === "Switch to Light Theme" && el?.tagName === "SPAN"),
).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"),
).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "Use System Theme" && el?.tagName === "SPAN"),
).toBeInTheDocument();
});
expect(screen.getByLabelText("Current theme")).toBeInTheDocument();
});
it("lists other workspaces under Switch Workspace and navigates on select", async () => {
const user = userEvent.setup();
mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" };
mockWorkspaces.current = [
{ id: "ws-current", name: "Current", slug: "current" },
{ id: "ws-alpha", name: "Alpha Co", slug: "alpha" },
{ id: "ws-beta", name: "Beta Co", slug: "beta" },
];
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "alpha");
await waitFor(() => {
expect(screen.getByText("Switch Workspace")).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"),
).toBeInTheDocument();
});
expect(screen.queryByText("Beta Co")).not.toBeInTheDocument();
expect(screen.queryByText("Current")).not.toBeInTheDocument();
const alphaItem = await screen.findByText(
(_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN",
);
await user.click(alphaItem);
expect(mockPush).toHaveBeenCalledWith("/alpha/issues");
expect(useSearchStore.getState().open).toBe(false);
});
it("shows all other workspaces when typing 'workspace'", async () => {
const user = userEvent.setup();
mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" };
mockWorkspaces.current = [
{ id: "ws-current", name: "Current", slug: "current" },
{ id: "ws-alpha", name: "Alpha Co", slug: "alpha" },
{ id: "ws-beta", name: "Beta Co", slug: "beta" },
];
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "workspace");
await waitFor(() => {
expect(screen.getByText("Switch Workspace")).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"),
).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "Beta Co" && el?.tagName === "SPAN"),
).toBeInTheDocument();
});
expect(screen.queryByText("Current")).not.toBeInTheDocument();
});
it("filters out recent items not present in query cache", () => {
mockRecentItems.current = [
{ id: "issue-1", visitedAt: 1000 },

View File

@@ -2,9 +2,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Check,
Clock,
Copy,
Link2,
Loader2,
MessageSquare,
Plus,
SearchIcon,
Inbox,
CircleUser,
@@ -12,19 +16,25 @@ import {
FolderKanban,
Bot,
Monitor,
Moon,
Sun,
BookOpenText,
Settings,
Building2,
type LucideIcon,
} from "lucide-react";
import { Command as CommandPrimitive } from "cmdk";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useRecentIssuesStore } from "@multica/core/issues/stores";
import { issueListOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core";
import { useWorkspacePaths } from "@multica/core/paths";
import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import type { WorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { StatusIcon } from "../issues/components";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
@@ -36,6 +46,7 @@ import {
DialogTitle,
DialogDescription,
} from "@multica/ui/components/ui/dialog";
import { useTheme } from "@multica/ui/components/common/theme-provider";
import { useNavigation } from "../navigation";
import { useSearchStore } from "./search-store";
@@ -106,19 +117,33 @@ const navPages: NavPage[] = [
{ key: "settings", label: "Settings", icon: Settings, keywords: ["settings", "config", "preferences"] },
];
type ThemeValue = "light" | "dark" | "system";
interface CommandItem {
key: string;
label: string;
icon: LucideIcon;
keywords: string[];
trailing?: React.ReactNode;
onSelect: () => void;
}
interface SearchResults {
issues: SearchIssueResult[];
projects: SearchProjectResult[];
}
export function SearchCommand() {
const { push } = useNavigation();
const { push, pathname, getShareableUrl } = useNavigation();
const open = useSearchStore((s) => s.open);
const setOpen = useSearchStore((s) => s.setOpen);
const recentItems = useRecentIssuesStore((s) => s.items);
const wsId = useWorkspaceId();
const p: WorkspacePaths = useWorkspacePaths();
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
const { theme, setTheme } = useTheme();
const currentWorkspace = useCurrentWorkspace();
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const recentIssues = useMemo(() => {
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
@@ -144,6 +169,145 @@ export function SearchCommand() {
);
}, [query]);
// Detect if current route is an issue detail page — /{slug}/issues/{id}.
// Falls back to null on any other route; used to gate issue-specific commands.
const currentIssue = useMemo(() => {
const match = pathname.match(/\/issues\/([^/]+)$/);
const raw = match?.[1];
if (!raw) return null;
const id = decodeURIComponent(raw);
return allIssues.find((i) => i.id === id) ?? null;
}, [pathname, allIssues]);
const commands = useMemo<CommandItem[]>(() => {
const activeThemeCheck = (value: ThemeValue) =>
theme === value ? (
<Check
aria-label="Current theme"
className="ml-auto size-4 shrink-0 text-muted-foreground"
/>
) : undefined;
const items: CommandItem[] = [
{
key: "new-issue",
label: "New Issue",
icon: Plus,
keywords: ["new", "issue", "create", "add"],
onSelect: () => {
useModalStore.getState().open("create-issue");
setOpen(false);
},
},
{
key: "new-project",
label: "New Project",
icon: Plus,
keywords: ["new", "project", "create", "add"],
onSelect: () => {
useModalStore.getState().open("create-project");
setOpen(false);
},
},
];
if (currentIssue) {
const identifier = currentIssue.identifier;
items.push(
{
key: "copy-issue-link",
label: "Copy Issue Link",
icon: Link2,
keywords: ["copy", "link", "share", "url", identifier.toLowerCase()],
onSelect: () => {
const url = getShareableUrl ? getShareableUrl(pathname) : window.location.href;
void navigator.clipboard.writeText(url);
toast.success("Link copied");
setOpen(false);
},
},
{
key: "copy-issue-identifier",
label: `Copy Identifier (${identifier})`,
icon: Copy,
keywords: ["copy", "id", "identifier", identifier.toLowerCase()],
onSelect: () => {
void navigator.clipboard.writeText(identifier);
toast.success(`Copied ${identifier}`);
setOpen(false);
},
},
);
}
items.push(
{
key: "theme-light",
label: "Switch to Light Theme",
icon: Sun,
keywords: ["light", "theme", "appearance", "mode", "bright"],
trailing: activeThemeCheck("light"),
onSelect: () => {
setTheme("light");
setOpen(false);
},
},
{
key: "theme-dark",
label: "Switch to Dark Theme",
icon: Moon,
keywords: ["dark", "theme", "appearance", "mode", "night"],
trailing: activeThemeCheck("dark"),
onSelect: () => {
setTheme("dark");
setOpen(false);
},
},
{
key: "theme-system",
label: "Use System Theme",
icon: Monitor,
keywords: ["system", "theme", "appearance", "mode", "auto"],
trailing: activeThemeCheck("system"),
onSelect: () => {
setTheme("system");
setOpen(false);
},
},
);
return items;
}, [currentIssue, getShareableUrl, pathname, setOpen, setTheme, theme]);
const filteredCommands = useMemo(() => {
const q = query.trim().toLowerCase();
// No query: only surface the primary creation action. Other commands
// (theme switches, copy actions, New Project) are revealed as the user
// types, leaving the empty-state space to Recent.
if (!q) return commands.filter((c) => c.key === "new-issue");
return commands.filter(
(c) =>
c.label.toLowerCase().includes(q) ||
c.keywords.some((kw) => kw.includes(q)),
);
}, [commands, query]);
// Only show workspaces different from the current one, and only after the
// user types >=2 chars — one char would match everything (e.g. "w").
const filteredWorkspaces = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
const others = workspaces.filter((w) => w.id !== currentWorkspace?.id);
const wantsAll =
q.length >= 2 && ("workspace".startsWith(q) || "switch".startsWith(q));
return others.filter(
(w) =>
wantsAll ||
w.name.toLowerCase().includes(q) ||
w.slug.toLowerCase().includes(q),
);
}, [workspaces, currentWorkspace?.id, query]);
const hasResults = results.issues.length > 0 || results.projects.length > 0;
// Global Cmd+K / Ctrl+K shortcut
@@ -262,6 +426,14 @@ export function SearchCommand() {
[push, setOpen, p],
);
const handleSwitchWorkspace = useCallback(
(slug: string) => {
push(paths.workspace(slug).issues());
setOpen(false);
},
[push, setOpen],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
@@ -317,17 +489,70 @@ export function SearchCommand() {
</CommandPrimitive.Group>
)}
{/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */}
{filteredCommands.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Commands
</div>
{filteredCommands.map((cmd) => (
<CommandPrimitive.Item
key={cmd.key}
value={`command:${cmd.key}`}
onSelect={cmd.onSelect}
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
>
<cmd.icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
<HighlightText text={cmd.label} query={query} />
</span>
{cmd.trailing}
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{/* Workspaces section — switch to a different workspace, only shown when query matches */}
{filteredWorkspaces.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Switch Workspace
</div>
{filteredWorkspaces.map((ws) => (
<CommandPrimitive.Item
key={ws.id}
value={`workspace:${ws.id}`}
onSelect={() => handleSwitchWorkspace(ws.slug)}
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
>
<Building2 className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
<HighlightText text={ws.name} query={query} />
</span>
<span className="ml-auto text-xs text-muted-foreground truncate">
{ws.slug}
</span>
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && query.trim() && !hasResults && filteredPages.length === 0 && (
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
No results found.
</CommandPrimitive.Empty>
)}
{!isLoading &&
query.trim() &&
!hasResults &&
filteredPages.length === 0 &&
filteredCommands.length === 0 &&
filteredWorkspaces.length === 0 && (
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
No results found.
</CommandPrimitive.Empty>
)}
{!isLoading && results.projects.length > 0 && (
<CommandPrimitive.Group
@@ -448,9 +673,8 @@ export function SearchCommand() {
)}
{!isLoading && !query.trim() && recentIssues.length === 0 && (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
<span>Type to search issues and projects...</span>
<span className="text-xs">Press <kbd className="rounded bg-muted px-1.5 py-0.5 font-medium">K</kbd> to open this anytime</span>
<div className="px-5 py-4 text-center text-xs text-muted-foreground">
Type to search issues and projects
</div>
)}
</CommandPrimitive.List>

Some files were not shown because too many files have changed in this diff Show More