Compare commits

..

79 Commits

Author SHA1 Message Date
Jiayuan
93d4094419 feat(inbox): remove redundant mark-as-done hover button, add archive button for done tasks
Remove the "mark as done" hover button from inbox list items since it
duplicates the one in the issue detail header. For done tasks, show an
archive button in the issue detail header instead.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 08:51:56 +02:00
Jiayuan Zhang
d9e5cf87dd fix(views): responsive Autopilot list for mobile viewports (#1961)
Switch Autopilot list rows to a stacked layout below the sm breakpoint,
hide desktop column headers on mobile, and match loading skeletons to
the mobile row shape. Desktop table layout is preserved at sm and above.

Closes MUL-1653

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 08:19:19 +02:00
Jiayuan Zhang
13fe614903 fix(daemon): optimize quick-create prompt for high-fidelity descriptions (#1969)
The previous description rule ("stay faithful + keep it concise") caused
agents to over-compress user input into vague single-sentence summaries,
losing context that the executing agent needs.

Key changes:
- Replace "keep it concise" with structured two-section format:
  User request (faithful restate) + Context (verifiable external facts)
- Add hard rules against information compression and semantic downgrading
- Remove "one-line description" phrasing (UI supports richer input)
- Strip redundant behavioral rules from issue_context.md (already
  covered by AGENTS.md guardrails and per-turn prompt)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 08:14:55 +02:00
wucm667
2305f7d180 fix(skill): sanitize null bytes in all skill update/upsert paths to prevent PostgreSQL UTF8 error (#1959) 2026-04-30 22:34:24 +02:00
Jay.TL
befde379b5 fix(runtimes): correct install script URL in connect remote dialog (#1949) 2026-04-30 14:57:33 +02:00
LinYushen
51fdc5aec3 Increase empty claim cache TTL (#1938) 2026-04-30 17:13:56 +08:00
Bohan Jiang
32d61d018e docs(changelog): publish v0.2.21 release notes (#1937)
* docs(changelog): publish v0.2.21 release notes

Adds the v0.2.21 entry to en.ts and zh.ts landing changelogs.
Highlights: Quick Capture overhaul, Mermaid diagrams in markdown,
typed project resources injected into agent runtime, permission-aware
UI, Presence v4, remote runtime wizard, and Inbox quality-of-life
improvements.

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): trim v0.2.21 entry to match prior release density

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): reword v0.2.21 project-repo feature

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 16:15:14 +08:00
Naiyuan Qing
51bc5a818f fix(onboarding): decouple from workspace state and route invitees correctly (#1936)
PR #1868 conflated "has workspace" with "completed onboarding" —
restore `onboarded_at` as the single signal, and route invited users
through a dedicated /invitations page before they ever see onboarding.

- Backend: CreateWorkspace + AcceptInvitation atomically set
  onboarded_at alongside the member insert, establishing the
  invariant "member row exists ↔ onboarded_at != null" at the DB
  layer.
- Migration 065: one-shot backfill closes the dirty rows produced
  by PR #1868 (users with a workspace but onboarded_at == null).
- Entry points (web callback, login, desktop App): if onboarded_at
  is null, look up pending invitations by email and route to the
  new batch /invitations page; otherwise the resolver picks
  workspace / new-workspace as before.
- OnboardingPage: stops bouncing on hasWorkspaces; only
  hasOnboarded bounces. Unblocks the user from completing
  Step 3 (workspace creation) → Steps 4 / 5.
- StarterContentPrompt: only shows when the user is the solo
  member of the workspace, so invited users never get prompted to
  import starter content into someone else's workspace.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:05:53 +08:00
Bohan Jiang
2dddfaa196 feat(daemon): Redis empty-claim fast path for /tasks/claim polling (#1860)
* feat(daemon): Redis empty-claim fast path for /tasks/claim polling

Daemons poll /tasks/claim every 30s per runtime; the steady-state
warm-empty case currently runs ListPendingTasksByRuntime against
Postgres on every poll. This collapses that path:

- New ListQueuedClaimCandidatesByRuntime query restricts to status =
  'queued' (the old query also returned 'dispatched' rows that can
  never be reclaimed) and is backed by a partial index keyed on
  (runtime_id, priority DESC, created_at ASC).
- New EmptyClaimCache caches the negative verdict in Redis with a
  30s TTL. ClaimTaskForRuntime checks the cache before SELECT and
  populates it on confirmed-empty results.
- notifyTaskAvailable now invalidates the runtime's empty key before
  kicking the daemon WS, so newly enqueued tasks become claimable
  immediately rather than waiting out the TTL.
- AutopilotService.dispatchRunOnly now goes through
  TaskService.NotifyTaskEnqueued so run_only tasks get the same
  invalidate-then-wakeup contract as every other enqueue path.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): close MarkEmpty/Bump race in empty-claim fast path

GPT-Boy's review on PR #1860 caught a real concurrency bug. Under the
prior implementation it was possible for a slow claim to write an
empty verdict AFTER a concurrent enqueue had already invalidated it:

  T1 claim:   SELECT -> empty
  T2 enqueue: INSERT row, DEL empty key (no-op, key not set yet),
              wakeup
  T1 claim:   SET empty (writes a stale "empty" verdict)
  T3 wakeup:  IsEmpty -> hit -> returns null

The just-queued task would then sit idle until the empty key's TTL
expired (up to 30s).

Replace the DEL-based invalidation with a per-runtime version
counter:

- CurrentVersion(rt) is a Redis INCR counter at
  mul:claim:runtime:version:<rt> with a 24h sliding TTL.
- Claim samples version BEFORE the SELECT and passes it to MarkEmpty,
  which stores the verdict's value as the observed-version string.
- IsEmpty MGETs both keys and trusts the verdict only when the
  empty-key value equals the current version.
- Enqueue Bumps the version (INCR + EXPIRE) before the wakeup,
  causing any verdict written under a prior version to be rejected
  on the next read.

Also bound every Redis call from this cache with a 250ms timeout —
notifyTaskAvailable uses a background context so a wedged Redis
must not block enqueue.

Tests against a real Redis (REDIS_TEST_URL) cover:
- MarkEmpty + IsEmpty under matching version returns hit
- Bump invalidates a prior empty verdict (race-fix pin)
- A MarkEmpty written under a stale pre-Bump version is rejected
- TTL clamping, per-runtime isolation, nil-cache safety
- notifyTaskAvailable Bumps before the wakeup fires

Co-authored-by: multica-agent <github@multica.ai>

* chore(daemon): renumber claim-candidate index migration to 067

Slot 064 was taken on main by 064_notification_preference. The
migration runner tracks per-version in schema_migrations and would
silently skip the second 064_*, leaving the index uncreated.
Rename to 067 (next free slot).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 15:50:05 +08:00
Ayman Alkurdi
cbe7f2c886 fix(api): batch-update no-op responses report updated=0 (#1660) (#1759)
The `POST /api/issues/batch-update` handler walked every issue ID and
incremented `updated` regardless of whether the iteration carried any
mutation. When the caller's payload had no recognized field in
`updates` — e.g. status placed at the top level instead of nested,
"update" misspelled as singular, or "updates" missing entirely —
the loop ran N no-op UPDATEs (each if-guard skipped, each COALESCE
preserved the existing value) and the response cheerfully reported
`{"updated": N}` while nothing changed. Reporters mistook the
positive count for success and chased a phantom persistence bug.

Detect at the top of the handler whether any known mutation field is
present in the parsed `updates` payload; if none is, short-circuit
with `{"updated": 0}`. The wire shape stays 200 + `{updated}`
so existing callers don't break — only the count becomes truthful.

Tests cover the three caller shapes that hit this path (status at top
level, empty `updates: {}`, misspelled "update") plus a positive
case that locks in happy-path persistence and counting.

Closes #1660.
2026-04-30 15:35:12 +08:00
Bohan Jiang
1d1dedbf6e fix(daemon): reclaim disk on long-open issues + correct cancelled-status check (#1931)
* fix(daemon): reclaim disk on long-open issues + correct cancelled-status check

Two related fixes for GitHub #1890 (self-hosted disk space growth):

- The GC's done/cancelled branch compared `status.Status` against `"canceled"`
  (single l), but the issue schema and the rest of the daemon use `"cancelled"`
  (double l). Cancelled issues therefore never matched and only fell out via the
  72h orphan TTL, which itself doesn't fire because cancelled issues are still
  reachable. Aligning the spelling lets cancelled-issue task dirs be reclaimed
  on the normal TTL path.

- Add a third GC mode, artifact-only cleanup, for the common case the report
  flagged: an issue stays open for days while many tasks complete on it, so
  per-task `node_modules`, `.next` and `.turbo` directories accumulate without
  ever becoming GC-eligible. The new branch fires when `.gc_meta.completed_at`
  is older than `MULTICA_GC_ARTIFACT_TTL` (default 12h), the env root is not
  currently in use by an active task, and the issue is still alive. It removes
  only directories whose basename matches `MULTICA_GC_ARTIFACT_PATTERNS`
  (default narrow: `node_modules,.next,.turbo`); source, `.git`, `output/`,
  `logs/` and the meta file are preserved so subsequent tasks can still resume
  the workdir. Patterns containing path separators are dropped, `.git` subtrees
  are never descended into, symlinked matches are not followed, and every
  removal target is verified to live inside the task dir.

Bookkeeping: `Daemon` now tracks active env roots with a refcounted set so the
GC loop never reclaims a directory that is mid-execution; `runTask` claims the
predicted root early plus the prior workdir on reuse paths. The cycle log is
extended with bytes reclaimed and per-pattern counts so self-hosted operators
can see what was freed.

Docs: extend the daemon configuration table in CLI_AND_DAEMON.md with the new
GC env vars and add a Workspace garbage collection section explaining the
three modes and the artifact-pattern contract.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): protect active env root from full GC removal too

Address GPT-Boy's PR #1931 review: the active-root guard only fired in the
artifact-cleanup branch, leaving a real race on the full-removal paths. A
follow-up comment on a long-done issue dispatches a task that reuses the prior
workdir, but `CreateComment` does not bump issue.updated_at — so the issue
still satisfies the done+stale GCTTL window and `gcActionClean` would
`RemoveAll` the directory mid-execution. The orphan-404 path is similarly
exposed when a token's workspace access is in flux.

Move the `isActiveEnvRoot` check to the top of `shouldCleanTaskDir` so all
three delete actions (clean, orphan, artifact) skip an in-use env root in one
place, and drop the now-redundant guard from the artifact branch.

Add tests covering the three at-risk paths: active root + done/stale issue,
active root + 404 issue past orphan TTL, active root + no-meta orphan past
TTL.

Also align two stale comments noted in the same review: cleanTaskArtifacts now
documents that symlinks are skipped entirely (the previous note implied the
link itself was removed), and GCOrphanTTL no longer claims that 404s are
cleaned immediately — the implementation gates them on the same TTL.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 15:34:16 +08:00
Jiayuan Zhang
298ed75b1d fix(views): only show "Mark as Done" button on Inbox page (#1934)
The toolbar button was previously visible on all issue detail views.
Gate it on the `onDone` prop, which is only passed from InboxPage.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 09:31:45 +02:00
Jiayuan Zhang
47b5e38dc6 docs: add Multica name origin section to README (#1933)
Sync the "Why Multica?" content from the landing page About section
into both README.md and README.zh-CN.md, explaining the name's
connection to Multics and the multiplexing philosophy.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 09:30:54 +02:00
Bohan Jiang
da5dbc6224 refactor(repos): drop unused description + tighten create-project layout (#1930)
* refactor(repos): drop unused description + tighten create-project layout

Two related changes that touch the workspace-repos surface together.

1. Remove the per-repo `description` field everywhere it was threaded.
   The only place it ever surfaced was a markdown table column the daemon
   wrote into the agent runtime config, where most rows just read "—"
   anyway. Agents already discover project structure by running
   `multica project` / `multica issue` against the CLI, so the human-
   readable description string carried no real value while taking up an
   extra Settings input row and propagating through six layers (settings
   UI → workspace.repos jsonb → handler RepoData → daemon RepoData →
   repocache.RepoInfo → execenv.RepoContextForEnv).

   - Settings → Repositories drops the description input; the URL field
     now spans the whole row.
   - WorkspaceRepo TS type loses `description`; backend RepoData /
     RepoInfo / RepoContextForEnv all collapse to URL only.
   - Daemon's runtime_config Repositories block changes from a
     `| URL | Description |` markdown table to a simple bullet list.
   - Tests updated; jsonb residue in existing workspaces is dropped at
     normalize time, so no migration needed.

2. Tighten the Create Project modal footer: pull the Status / Priority /
   Lead / Repos pills onto the same row as the Create Project button
   (Linear-style single-row footer) instead of stacking them above it,
   and swap the Repos pill icon from `FolderGit` to a real GitHub mark
   (lucide-react v1 dropped brand icons, so the mark lives inline as a
   small SVG component in this file).

   I tried promoting Repos to its own "Resources" strip above the footer
   to separate the resources abstraction from project metadata, but with
   a single pill it looked too sparse — leaving a TODO comment in the
   footer to revisit once we add Linear / Notion / Figma / Slack
   resource types.

* fix(daemon test): drop residual Description field on RepoData literals

* fix(repos): drop Description residue surfaced after rebase on #1929

Project-resource github_repo lift path (#1929) and registerTaskRepos
both still constructed RepoData{...Description: ...} after the rebase.
Two test sites in daemon_test.go and execenv_test.go also reintroduced
the field. Strip them so the Description-removal change builds and
tests pass with the latest main.
2026-04-30 14:55:03 +08:00
Bohan Jiang
2129aa3dee feat(projects): project github_repo resources override workspace repos (#1929)
* feat(projects): project github_repo resources override workspace repos

When an issue's project has at least one github_repo resource, the daemon
claim handler now sends only those as resp.Repos — workspace-level repos
are hidden to avoid mixing two repo lists in the agent prompt. With no
project github_repos (or no project), behavior is unchanged: workspace
repos are surfaced as before.

Lifts each project github_repo's url (and label, when present) into a
RepoData entry so `multica repo checkout` and the meta-skill render the
same URLs. The full structured list still ships at
.multica/project/resources.json for skills that want everything.

Adds TestProjectReposReplaceWorkspaceReposInMetaSkill covering the
rendering side. Docs updated to spell out the new precedence.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): allow project repo URLs through the checkout allowlist

When ClaimTaskByRuntime narrows resp.Repos to project github_repo URLs,
the daemon receives URLs that may not exist in the workspace's
GetWorkspaceRepos response. The existing checkout flow rejected those
with ErrRepoNotConfigured because the allowlist (and cache) was built
only from workspace-bound repos.

Adds registerTaskRepos in daemon.runTask: before agent spawn, merge
task.Repos into a new task-scoped allowlist (separate from the
workspace-scoped one so a workspace refresh doesn't wipe project URLs)
and kick off a background cache sync. ensureRepoReady now treats either
allowlist as valid.

Tests:
- TestRegisterTaskReposAllowsProjectOnlyURL — project-only URL is
  checkout-able and does not trigger a workspace-repos refresh
- TestRegisterTaskReposSurvivesWorkspaceRefresh — task URLs persist
  across refreshWorkspaceRepos
- TestClaimTask_ProjectGithubReposOverrideWorkspaceRepos — claim
  handler returns only project repos when present, no workspace leakage
- TestClaimTask_ProjectWithoutRepos_FallsBackToWorkspaceRepos — fall
  back to workspace repos when project has no github_repo resources

Docs updated to spell out the daemon-side allowlist behavior.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:37:51 +08:00
Multica Eve
2fd388da08 fix: stabilize mobile issue detail layout (#1912)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-30 08:32:51 +02:00
Bohan Jiang
cba3db0d7f feat(markdown): add fullscreen lightbox for mermaid diagrams (#1927)
A sandbox="" iframe cannot run scripts, so users had no way to zoom or
pan rendered Mermaid diagrams beyond browser scrolling. Add a hover
toolbar with a fullscreen button that opens a portal-based lightbox
showing the same diagram scaled to 90vw x 90vh, while preserving the
sandbox isolation (the lightbox iframe is also sandbox=""). ESC or
clicking the backdrop closes the lightbox.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:20:41 +08:00
Bohan Jiang
b1345685a3 fix(task): rerun starts a fresh session, skip poisoned resume (#1928)
* fix(task): rerun starts a fresh session, skip poisoned resume

When a task ended in a known agent fallback ("I reached the iteration
limit and couldn't generate a summary.", "Put your final update inside
the content string. Keep it concise.") the (agent_id, issue_id) resume
lookup would still pick that session, so a manual rerun inherited the
poisoned state and reproduced the same bad output.

Two complementary guards:

1. Daemon classifies poisoned terminal output and routes it through the
   blocked path with failure_reason set ('iteration_limit' /
   'agent_fallback_message'). GetLastTaskSession excludes failed tasks
   with those reasons, so even comment-triggered tasks no longer resume
   them. Tasks that failed mid-flight (timeout, runtime_recovery, etc.)
   are still resumable, preserving MUL-1128's auto-retry contract.

2. Manual rerun marks the new task force_fresh_session=true. The daemon
   claim handler skips the resume lookup entirely when the flag is set,
   capturing the user-intent signal that "the prior output was bad" even
   when poisoned classification misses a future fallback wording.

Auto-retry of orphaned mid-flight failures (MaybeRetryFailedTask →
CreateRetryTask) does not take this path, so it keeps resuming.

Tests: classifyPoisonedOutput unit test; integration tests assert the
SQL filter excludes poisoned classifiers, RerunIssue flips the flag,
and the normal enqueue path leaves it false.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): cap poisoned-output matcher to short trimmed text

GPT-Boy review on MUL-1630: the previous strings.Contains match would
classify any output that quoted the marker substring — including a
review/analysis that simply discussed the marker itself. Real fallback
messages are short single-sentence affairs, so cap the candidate at
~one paragraph and trim whitespace before matching. Adds regression
tests covering a long quoting review and a marker buried in a long
real conclusion; both must stay classified as completed.

Co-authored-by: multica-agent <github@multica.ai>

* fix(migrations): rename 065 force_fresh_session → 066 to clear collision

main introduced 065_project_resources after this branch was cut, so
both files shared the 065_ prefix. The readiness check
(server/cmd/server/health.go → migrations.LatestVersion) takes the
last entry by lexical order, which is 065_project_resources, leaving
this branch's 065_force_fresh_session unguarded — a deploy that
applied project_resources but not force_fresh_session would still
report ready, and the next enqueue / rerun / claim would crash on
"column force_fresh_session does not exist".

Renaming to 066_force_fresh_session puts it strictly after
project_resources so readiness blocks until it's applied.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:17:53 +08:00
Bohan Jiang
44608713bb feat(projects): typed project resources + agent runtime injection (#1926)
* feat(projects): typed project resources + agent runtime injection

Adds a `project_resource` table that lets a project carry typed pointers
(github_repo today, more later) and surfaces them at agent runtime.

Server
- migration 065: project_resource (resource_type TEXT + resource_ref JSONB)
- sqlc CRUD + handler at /api/projects/{id}/resources
- claim handler attaches project_id/title + resources to issue tasks

Daemon
- TaskContextForEnv carries project context
- writes .multica/project/resources.json into workdir
- adds "## Project Context" block to CLAUDE.md / AGENTS.md / GEMINI.md
  via type-dispatched formatter so new resource types just add a case

CLI
- multica project create --repo <url> attaches repos in one step
- multica project resource add/list/remove

Frontend
- Project create modal: Repos pill (workspace repos + ad-hoc URL)
- Project detail sidebar: collapsible Resources section with attach/remove

Docs
- New "Project Resources" chapter explaining the abstraction and
  exactly what code to touch when adding a new resource type

Co-authored-by: multica-agent <github@multica.ai>

* fix(projects): transactional resources[] on create + generic CLI ref + test fix

Addresses review feedback on PR #1926:

1. CI red: TestProjectResourceLifecycle delete step called withURLParam
   twice, which replaced the chi route context and dropped the project id.
   Switched to the existing withURLParams helper from daemon_test.go.

2. POST /api/projects now accepts resources[] and attaches them in the
   same transaction as the project. Invalid refs roll back the whole
   create — no more half-attached projects on failure. Web modal + CLI
   `project create --repo` both use the new bundled payload.

3. CLI `project resource add` now accepts a generic --ref '<json>' flag
   so a new resource_type works without a CLI change. Per-type
   shortcuts (--url for github_repo) remain as a convenience but are no
   longer the only way in. Docs updated to drop the CLI from the
   "files you must touch" list.

Adds two new server handler tests:
- TestCreateProjectAttachesResources (resources[] happy path)
- TestCreateProjectRollsBackOnInvalidResource (transactional rollback)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:00:43 +08:00
Prince Pal
a28312c0b4 feat(markdown): render mermaid diagrams (#1888)
* feat(markdown): render mermaid diagrams

* fix(markdown): harden mermaid diagram rendering

* fix(markdown): address mermaid review feedback

* fix(markdown): strengthen mermaid theme handling

* fix(markdown): rasterize mermaid theme colors
2026-04-30 13:27:01 +08:00
Bohan Jiang
72d5135bf0 fix(quick-create): subscribe requester to issues created via quick-create (#1924)
The agent runs the daemon CLI, so issue.creator_type is `agent` and the
issue:created event listener only auto-subscribes the agent — not the
human requester. Result: the requester gets a single completion inbox
item but never sees follow-up comments or updates on their own issue.

Subscribe the requester (reason=`creator`, the only matching value
allowed by issue_subscriber's CHECK constraint without a migration)
inside notifyQuickCreateCompleted, after the issue lookup succeeds and
before the inbox write. Best-effort: log on failure, don't block the
inbox. On success, publish subscriber:added so the UI stays in sync
with manual subscribe and the listener-driven path.

Adds two integration tests in cmd/server: success path subscribes the
requester; failure path (agent finished without creating an issue)
leaves no subscriber rows.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 13:19:34 +08:00
Prince Pal
924c69114d feat(daemon): expose concurrent task slot env (#1889)
* feat(daemon): expose concurrent task slot env

* fix(daemon): address task slot review nits
2026-04-30 12:56:40 +08:00
Multica Eve
700e6f3f24 fix: prevent mobile input focus zoom
Add a shared mobile/coarse-pointer CSS guard that keeps focused text-editing controls at 16px to avoid iOS Safari page zoom.
2026-04-30 12:28:22 +08:00
Naiyuan Qing
d68f1f4bf1 fix(issues): wrap Details and Token usage sections in grid (#1921)
PropRow switched to CSS subgrid in #1919, which requires its parent to
declare grid columns. The Properties section's wrapper was updated, but
Details and Token usage in the same file were missed — their PropRows
collapsed to a single column, stacking label and value vertically.

Add the same `grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5` wrapper used
by Properties so all three sections render consistently.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:59:14 +08:00
Naiyuan Qing
281779330e feat(chat): no-agent disabled state with onboarding fix and editor cleanup (#1919)
* fix(onboarding): refresh agent cache after import and agent creation

Two paths could leave the workspace agent-list query cache stale by the
time the dashboard rendered the welcome issue, causing the issue's
agent assignee to resolve to "Unknown Agent":

1. StarterContentPrompt.onImport invalidated pins/projects/issues but
   not agents, and didn't await any of them before navigating — so the
   issue-detail page could mount and read the cache before TanStack
   Query had marked the relevant queries stale.
2. OnboardingFlow.handleAgentCreated created the agent without
   invalidating the agent list, so the dashboard's first mount would
   read whatever was already cached from earlier in onboarding.

Both now invalidate workspaceKeys.agents, and the import flow awaits
all invalidations via Promise.all before pushing the navigation, so
the next page mount always refetches.

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

* refactor(editor): drop editable prop, ContentEditor is editing-only

ContentEditor's `editable` prop had zero true callsites left in the
codebase — every read-only surface had migrated to ReadonlyContent
(react-markdown), and the prop only invited misuse: Tiptap's
`useEditor` reads `editable` at mount, so callers that toggled it
post-mount (like a chat input that needs to disable on no-agent)
silently got stuck in whichever mode the editor first created.

Changes:
- Remove `editable` prop and default; useEditor and createEditorExtensions
  no longer take it.
- Remove the `"readonly"` className branch and the readonly content sync
  useEffect (only the editing path remains).
- Remove the BubbleMenu and mouseDown editable guards.
- Drop LinkReadonly; rename LinkEditable to LinkExtension and use it
  unconditionally.
- Update the docstring to point readers at ReadonlyContent for display
  surfaces.

ReadonlyContent's `.readonly` CSS class stays in content-editor.css —
that file's selectors are still used by react-markdown's wrapper.

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

* feat(chat): empty-state by session history, no-agent disabled state

Three independent improvements to the chat window's pre-conversation
states, sharing a new three-state availability primitive:

1. New `useWorkspaceAgentAvailability()` hook (`"loading" | "none" |
   "available"`) so callers don't have to reinvent the loading-vs-empty
   distinction. Treating loading as "no agent" — the easy mistake —
   caused the chat input to flash a fake disabled state for the few
   hundred ms after mount, even when the workspace had agents.
2. EmptyState now branches on session history, not agent presence:
   never-chatted users get a short pitch ("They know your workspace —
   issues, projects, skills"), returning users get the existing
   starter prompts. Missing-agent feedback moved to the banner above
   the input, keeping this surface focused on "what is chat for".
3. No-agent disabled state: when availability resolves to "none",
   ChatInput dims and stops responding to clicks/keys, with cursor
   `not-allowed` on hover. The disable lives at the wrapper level
   (`pointer-events-none` on the inner card, `cursor-not-allowed` on
   the outer one — splitting layers so hover bubbles to where the
   browser reads cursor) — we no longer reach into the editor's
   editable mode, which never switched cleanly post-mount anyway.
   A `<NoAgentBanner>` (sibling of OfflineBanner, mutually exclusive)
   states the prerequisite without linking out — no one should be
   pulled out of chat mid-thought to a settings page.

Also: default chat width 420 → 380, since the chat docks at the
bottom-right and 420 was crowding everything else.

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

* refactor(views): align PropRow labels using CSS subgrid

The fixed `w-16` (64px) label column on PropRow broke whenever a label
rendered wider than 64px (e.g. "Concurrency" in the agent inspector) —
the label would overflow into the gap and collide with the value.

Switch to subgrid: the parent declares `grid grid-cols-[auto_1fr]` and
each PropRow becomes `col-span-2 grid grid-cols-subgrid`. The `auto`
track sizes to the widest label across all rows in that parent, so
labels always fit and value columns stay aligned across rows without
picking a magic pixel width.

Updated parents:
- agent-detail-inspector Section wrapper
- issue-detail Properties group

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-30 11:32:35 +08:00
Naiyuan Qing
949dffdf7e feat: permission-aware UI across agent/comment/runtime/skill surfaces (#1915)
* feat(permissions): add core permission module and shared UI primitives

Foundation for permission-aware UI: pure rules that mirror the Go backend
permission gates, lightweight per-resource hooks, and two reusable display
components used across agent/skill/runtime detail pages.

- packages/core/permissions: types, rules, hooks (Decision-shaped — carries
  reason + message so UI can render disabled state, tooltip, and banner
  copy from one source)
- packages/core/agents/visibility-label: VISIBILITY_LABEL/DESCRIPTION/TOOLTIP
  constants ("Personal" / "Workspace") to replace scattered hard-coded copy
- packages/views/agents/visibility-badge: read-only visibility chip used on
  hover cards, list rows, and inspector when not editable
- packages/ui/components/common/capability-banner: "View only — only X and
  admins can edit Y" banner shown on agent / skill detail when current user
  lacks edit permission

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

* feat(views): permission-aware UI across agent/comment/runtime/skill surfaces

Apply the new permission rules to every surface where the UI was either
lying about who can do what or letting users hit 403s by clicking buttons
the backend would reject.

Agent detail
- Hide archive/restore actions for non-owner non-admin
- Replace inline editors (avatar, name, description, runtime/model/visibility/
  concurrency picker, skill-attach) with read-only display when canEdit is
  false — value is information, the editor is the action
- Show CapabilityBanner under the header explaining who can edit

Visibility surfaces
- visibility-picker / create-agent-dialog: replace "only you can assign"
  (false) with "Only you and workspace admins can assign" via shared
  VISIBILITY_DESCRIPTION constants
- agent-columns: truthful tooltip + "You" badge on agents the current user
  owns

Comments
- Restore admin override on comment edit/delete (backend already permits
  it via comment.go:507-512; the frontend was incorrectly hiding the menu).
  canModerate is computed once in issue-detail and threaded down.

Other
- Members tab: disable "demote" options for the last owner with tooltip
- Assignee picker: tooltip on disabled personal agents the user can't assign
- Runtime delete: tooltip and dialog explain the gate; owner column gains
  a name label next to the avatar in All scope
- Skill detail: page-level CapabilityBanner alongside the existing lock chip
- Issue delete (single + batch): note that any workspace member can delete
  issues — by-design semantics, made transparent

Backend is unchanged.

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

* feat(agents): hide personal agents from list and @mention for non-owners

Until now an agent's "Personal" visibility only narrowed the assign-to-issue
gate — every workspace member still saw every personal agent in the list
and the @mention dropdown. Members would see, click, and fail.

This filters those surfaces with the canonical canAssignAgentToIssue rule:
regular members only see workspace-visibility agents and the personal
agents they own; workspace owners and admins continue to see everything
(admin override path is intact).

- agents-page: visibleInView layer between active/archived and Mine/All
  scope so segment counts also reflect the filter
- mention-suggestion: filter agentItems before they enter the recency-
  ranked list; expand the test mock to cover the auth + visibility paths
  and add two assertions (member hides others' personal agents; admin
  still sees them)

Backend keeps returning every agent — admin tools and direct API access
are unaffected. This is a UI-only filter.

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-30 09:31:19 +08:00
Naiyuan Qing
e6e9c64484 refactor(chat): simplify task-status-pill (#1914)
Three signal axes (color / label tiers / per-tool spinner) collapsed
into one (label only):

- Drop 60s amber warning color and 300s cancel-button threshold. The
  cancel button duplicated ChatInput's Stop button (both call the same
  handleStop) — single entry point is enough; users can judge from the
  elapsed seconds whether to stop.
- Drop tiered thinking labels (Thinking / Reasoning / Working through
  it / Taking a closer look) — collapse to a single "Thinking".
- Unify all spinners to `breathe` (was: helix / scan / cascade / orbit
  / breathe / pulse / braille mix). Tool-specific spinner choices were
  cosmetic noise; one consistent spinner reads cleaner.
- Remove `onCancel` prop chain through ChatMessageList → TaskStatusPill.

Net: 209 → 152 lines in task-status-pill.tsx; no API/contract changes
beyond removing a now-unused prop.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:15:34 +08:00
Prince Pal
c6a26facd3 fix(inbox): jump instantly to targeted comments (#1887) 2026-04-29 23:25:01 +02:00
Jiayuan Zhang
b6a3f8ed58 feat(daemon): add Co-authored-by trailer for Multica Agent to git commits (#1907)
* feat(daemon): add Co-authored-by trailer for Multica Agent to git commits

Install a prepare-commit-msg hook in worktree bare repos that appends
"Co-authored-by: multica-agent <github@multica.ai>" to every commit
made by agents. Uses git interpret-trailers for proper formatting and
skips duplicates.

* feat(settings): add Co-authored-by toggle in workspace Labs settings

Add a workspace-level toggle to enable/disable the Co-authored-by
trailer for agent commits. Default is enabled (on).

Backend:
- Include workspace settings in daemon register response
- Store settings in daemon workspaceState
- Thread CoAuthoredByEnabled through WorktreeParams to conditionally
  install the prepare-commit-msg hook
- Parse co_authored_by_enabled from workspace settings JSONB

Frontend:
- Replace empty Labs tab placeholder with a Git section containing
  a Switch toggle for the Co-authored-by trailer setting
- Optimistically update the workspace query cache on toggle

* chore(daemon): skip squash commits in Co-authored-by hook

Test commit to verify the prepare-commit-msg hook appends the
Co-authored-by trailer automatically.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-29 23:02:50 +02:00
Jiayuan Zhang
8c9c52b023 feat(inbox): add notification preferences to control inbox noise by event type (#1906)
Users can now mute specific notification categories (assignments, status
changes, comments & mentions, priority/due-date updates, agent activity)
from Settings > Notifications. Muted event types are silently filtered at
notification creation time — no inbox items are created for muted groups.

- Add notification_preference table (migration 064)
- Add GET/PUT /api/notification-preferences endpoints
- Filter notifications in notifyIssueSubscribers, notifyDirect, and
  notifyMentionedMembers based on user preferences
- Add Notifications tab in Settings with per-group toggle switches
2026-04-29 22:51:29 +02:00
Jiayuan Zhang
562949e1cb fix(daemon): prevent Quick Create from inventing requirements beyond user input (#1903)
The description rule in buildQuickCreatePrompt() instructed the agent to
"always provide a rich, self-contained description" and "spell out what
needs to be done", which caused the agent to fabricate detailed product
specs, implementation phases, and design decisions from a one-line input.

Replace with a faithfulness-first rule: enrich with factual context
(fetched PR details, linked resources) but never invent requirements,
design decisions, or constraints the user did not express.

Fixes MUL-1605
2026-04-29 21:12:17 +02:00
Jiayuan Zhang
65f6e9c9f2 feat(autopilots): show execution log button for run-only autopilot runs (#1901)
In run-only mode, autopilot runs don't create issues, so there was no
way to view the agent's execution transcript from the UI. Add a
TranscriptButton to each run row that has a task_id but no linked
issue, allowing users to lazy-load and inspect the full execution log
directly from the autopilot detail page.
2026-04-29 19:10:49 +02:00
Jiayuan Zhang
79d28b0da6 fix(agents): navigate to detail page before invalidating list query (#1897)
After creating an agent from the empty state, the query invalidation
triggered a refetch that re-rendered the agents list page (empty → list)
before navigation to the detail page completed, causing a visible flash.

Move navigation.push() before qc.invalidateQueries() so the user lands
on the detail page immediately; the list refetch happens in the
background after we've already left.
2026-04-29 18:22:56 +02:00
Jiayuan Zhang
aeccd4f26e feat(quick-create): enrich issue title and description with URL context (#1892)
* feat(quick-create): enrich issue title and description with URL context

Update the quick-create agent prompt to fetch context from URLs in user
input (GitHub PRs, issues, web pages) before creating the issue. The
agent now produces semantically rich titles (e.g. "Review PR #123:
Refactor auth to OAuth2" instead of "review PR #123") and includes
summarized link content in the description so issues are self-contained.

* refactor(quick-create): let agent decide when to fetch URL context

Replace prescriptive URL enrichment instructions (hardcoded gh/WebFetch
commands) with goal-oriented guidance. The agent now uses its own
judgment to decide whether fetching referenced URLs would produce a
meaningfully better title/description, rather than being told exactly
which tools to use.

* fix(quick-create): always generate rich description for agent execution

The description was previously optional ("omit if simple request"). Since
quick-create issues are executed by agents, richer context leads to
better execution — update the prompt to always produce a substantive
description with actionable context.

* fix(quick-create): remove Chinese text from prompt, use English only

Replace Chinese examples in priority mapping and assignee matching with
language-agnostic English equivalents, per project coding rules.

* fix(quick-create): remove language-related hints from prompt

Agent doesn't need to be told about language handling — remove
"(in any language)" and "or equivalent in any language" qualifiers.
Keep prompt purely in English with no language-related content.
2026-04-29 18:19:11 +02:00
Jiayuan Zhang
68ed2a32d9 fix(desktop): prevent Cmd+R / Ctrl+R / F5 from reloading the page (#1896)
In a desktop app an accidental page reload destroys in-memory state
(tabs, drafts, WS connections) with no URL bar to navigate back.

Add a before-input-event listener on the main BrowserWindow that
intercepts Cmd+R / Ctrl+R (with or without Shift) and F5, calling
preventDefault() to block the reload. DevTools refresh still works.
2026-04-29 18:18:01 +02:00
Jiayuan Zhang
f508190065 feat(modals): persist drafts for create-project and feedback modals (#1894)
Add Zustand persisted draft stores for the create-project and feedback
modals, following the same pattern as the existing issue draft store.
Drafts are saved to localStorage on every field change and restored
when the modal reopens, preventing accidental data loss on close.
Draft is cleared on successful submit.
2026-04-29 17:58:19 +02:00
Jiayuan Zhang
d5611d550a fix(inbox): auto-archive inbox item when marking done from issue detail (#1893)
When viewing an inbox notification's issue detail and clicking the "Mark
as done" toolbar button, the inbox item was not archived — only the issue
status changed. Add an onDone callback to IssueDetail so the inbox page
can archive the notification alongside the status update, matching the
behavior of the list-item Done button.

Closes MUL-1594
2026-04-29 17:57:00 +02:00
Jiayuan Zhang
28b29ec5ee feat(views): add remote machine / AWS EC2 connection wizard to Runtimes page (#1886)
* feat(views): add remote machine / AWS EC2 connection wizard to Runtimes page

Add a "Connect remote machine" CTA to the Runtimes page header and
empty state that opens a 3-step wizard dialog guiding users through:

1. Installing the Multica CLI on a remote machine
2. Configuring, logging in with a PAT, and starting the daemon
3. Monitoring for runtime registration via WebSocket

Includes security tips (IAM roles, no root keys), troubleshooting
guidance (daemon status/logs, CLI version check), and post-connection
flow to create an agent on the newly registered runtime.

Closes MUL-1588

* fix(views): improve connect-remote dialog layout and usability

- Widen dialog from sm:max-w-lg to sm:max-w-xl for longer commands
- Add max-h-[85vh] + overflow-y-auto so content scrolls on small screens
- Split monolithic code block into 4 separate labeled steps (install,
  configure, login, start daemon) — each with its own copy button
- Make copy buttons always visible instead of hover-only
- Condense security tips into a single compact paragraph
- Tighten vertical spacing throughout
2026-04-29 17:35:45 +02:00
Jiayuan Zhang
b98c2a5a0f feat(inbox): add one-click Done button to inbox items (#1885)
* feat(inbox): add one-click Done button to inbox items

Add a hover-visible "Mark as done" button (CircleCheck icon) to each
inbox item that has an associated issue not yet in done/cancelled status.
Clicking it sets the issue status to "done" and archives the inbox item
in one action, replacing the previous multi-step flow of opening the
issue detail sidebar to change status.

* feat(issues): add Mark Done button to issue detail toolbar

Add a "Mark as done" button (CircleCheck icon) to the issue detail
header toolbar, positioned to the left of the Pin button. The button
is only visible when the issue status is not already done or cancelled.
Clicking it sets the issue status to "done" via the existing
handleUpdateField action.
2026-04-29 16:07:34 +02:00
Multica Eve
b9118ae9b8 Refine Quick Create agent modal (#1879)
* fix: refine quick create agent modal

* fix: align quick create toolbar feedback

* fix: sync create mode toolbar options

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 15:55:00 +02:00
Multica Eve
06880d6ba2 fix: make workspace table columns resizable (#1881)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Jiayuan Zhang <forrestchang7@gmail.com>
2026-04-29 15:23:12 +02:00
Multica Eve
472e78022e fix: improve quick create inbox previews (#1883)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Jiayuan Zhang <forrestchang7@gmail.com>
2026-04-29 20:56:27 +08:00
elrrrrrrr
5bf0e7022d fix(auth): route invitees to their workspace instead of forcing /onboarding (#1868)
* fix(auth): route invitees to their workspace instead of forcing /onboarding

Workspace presence now wins over `onboarded_at` across every post-auth
entry point, so a user invited into an existing workspace lands inside
that workspace instead of being trapped in the new-workspace wizard.

The redesigned onboarding flow (#1411) intentionally flipped the
priority during frontend development so every login re-entered
/onboarding; the backend `onboarded_at` field shipped but the flipped
priority was never restored. Closes #1837.

- packages/core/paths/resolve.ts: has-workspace beats !hasOnboarded.
  Onboarding is reachable only when the user has zero workspaces.
- apps/web/app/auth/callback/page.tsx: drop the early-return on
  !onboarded so a `next=/invite/<id>` survives Google OAuth round-trips.
- apps/web/app/(auth)/login/page.tsx: same removal in both the
  already-authenticated effect and the post-login handler.
- packages/views/layout/use-dashboard-guard.ts: stop bouncing in-workspace
  users to /onboarding; rely on the resolver for zero-workspace cases.
- apps/desktop/src/renderer/src/App.tsx: window-overlay now opens
  onboarding only when wsCount === 0 AND !hasOnboarded.
- apps/web/app/(auth)/onboarding/page.tsx: defense-in-depth — bounce
  away if the visitor already has a workspace, even on direct URL access.

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

* test(auth): fix URLSearchParams leaking state across callback tests

The previous cleanup `mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k))`
silently skipped entries because forEach advances its index while the
underlying URLSearchParams shrinks, so a `state=next:/invite/...` set
in one test bled into the next. Snapshot keys via Array.from before
deleting. Also rewrites the assertions to match the new policy: an
unonboarded user with a safe `next=` honors it, with a workspace lands
in that workspace, and only with zero workspaces falls back to
/onboarding.

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-29 20:53:58 +08:00
Multica Eve
665ac39730 fix(ci): restore frontend checks (#1878)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 14:49:42 +02:00
Jiayuan Zhang
55b7e2e93a fix(views): stop showing hardcoded model name in default model display (#1875)
When no model is explicitly selected, the model dropdown and inspector
picker no longer show "Default — Claude Sonnet 4.6". Instead they show
"Default (provider)" / "Default", avoiding confusion when the actual
CLI default differs from the hardcoded catalog entry.
2026-04-29 14:18:01 +02:00
Jiayuan Zhang
80c5bb9e9e feat(views): quick capture continuous creation mode (#1863)
* feat(views): keep quick capture open after submit for continuous creation

After successfully sending a prompt to the agent, the dialog now clears
the editor and stays open instead of closing. This lets users create
multiple issues in quick succession without reopening the dialog each
time. The user can still close manually via X or Escape.

* feat(views): add success feedback for quick capture continuous mode

After each successful submit, the Create button briefly flashes green
with a checkmark "✓ Sent" for 1.5s, then reverts. A persistent counter
("N sent") appears in the footer so the user knows how many prompts
they've dispatched in this session. No explicit mode toggle needed —
the counter implicitly signals continuous mode is active.

* feat(views): add "Create another" toggle to quick capture (Linear-style)

Replace always-on continuous mode with an opt-in toggle switch in the
footer, matching Linear's "Create more" pattern. The preference is
persisted per-workspace via the quick-create store so it remembers
across sessions.

- Toggle OFF (default): submit closes the dialog (original behavior)
- Toggle ON: submit clears the editor and stays open; button flashes
  green "✓ Sent" and a counter shows how many have been dispatched

* fix(views): remove stale breadcrumb identifier test

PR #1872 removed the issue identifier from the breadcrumb but the
corresponding test was not updated, causing CI to fail.
2026-04-29 14:15:14 +02:00
Jiayuan Zhang
6a665c68a3 fix(inbox): improve quick-create notification to show issue title prominently (#1873)
The inbox notification for quick-create showed "Created MUL-1577: <title>"
which truncated the actual issue title. Now the title field shows just the
issue title (the most useful info), and the detail label shows "Created
MUL-XXXX" as context.
2026-04-29 13:54:08 +02:00
Jiayuan Zhang
174b8c62a6 fix(views): remove redundant issue identifier from breadcrumb navigation (#1872)
The issue detail page breadcrumb showed both the issue identifier and
title (e.g. "MUL-1567 Title"), making the ID appear twice. Remove the
standalone identifier span so only the title is displayed.
2026-04-29 13:50:43 +02:00
Jiayuan Zhang
768d3f8b0c feat(ui): make New Issue button open Quick Capture instead of manual form (#1862)
* feat(ui): make New Issue button open Quick Capture instead of manual form

The sidebar "New Issue" button and the search command's "New Issue" action
now open the agent-based Quick Capture dialog directly, matching the
platform's agent-first workflow.

Contextual issue creation (board columns, list view status groups, sub-issues)
still opens the manual form since those pass pre-filled data.

Closes MUL-1558

* test(search): update search-command test to expect quick-create-issue

Aligns the test assertion with the behavior change in the previous
commit where "New Issue" now opens Quick Capture.
2026-04-29 13:48:50 +02:00
Jiayuan Zhang
7dfa72465c feat(quick-create): add file upload button to Quick Capture dialog (#1866)
The agent-mode Quick Capture dialog already supported image paste and
drag-drop through the ContentEditor, but lacked a visible file
attachment button. This made the feature undiscoverable.

Add a FileUploadButton (paperclip icon) to the footer, matching the
pattern already used by the manual create panel and comment input.
2026-04-29 13:48:44 +02:00
Jiayuan Zhang
0b969483a6 fix(quick-create): block submit while image uploads are in progress (#1864)
Without this guard, submitting during an active upload causes
stripBlobUrls to silently remove the in-flight blob image from the
markdown, so the agent never sees the pasted screenshot. Now the Create
button disables and shows "Uploading…" until all file uploads resolve.
2026-04-29 13:48:35 +02:00
Bohan Jiang
e024ab1232 fix(desktop): show git-described version in dev instead of stale 0.1.0 (#1867)
Packaged builds are unaffected: scripts/package.mjs already injects the
git tag into electron-builder's extraMetadata.version, so the .app users
download from GitHub Release reports the right version through
app.getVersion() and the auto-updater's latest.yml comparison works
correctly.

Dev mode (`pnpm dev:desktop`) didn't go through that path though, so
app.getVersion() returned the static "0.1.0" from package.json — the
new Settings → Updates panel surfaced this and made it look like the
dev build was ancient. Add a tiny getAppVersion() helper that falls
back to `git describe --tags --always --dirty` only when !app.isPackaged,
and use it for the app-info IPC. No change to packaged behavior; if git
is unavailable for any reason, we silently fall back to app.getVersion().
2026-04-29 19:18:41 +08:00
Bohan Jiang
f4eb83bd41 feat(desktop): show current version in Updates settings (#1861)
Surface the running app version (from app.getVersion via preload's
appInfo) at the top of Settings → Updates so users have a clear place
to check which build they're on, instead of only seeing it inline after
clicking "Check now".
2026-04-29 19:07:39 +08:00
Jiayuan Zhang
dde42ba84a fix(views): remove Sparkles icon before "Created by" in quick capture dialog (#1859)
Removes the Sparkles icon from the agent picker trigger in the
quick-create-issue dialog, keeping only the "Created by" text label.
2026-04-29 12:51:08 +02:00
Naiyuan Qing
9467a8c616 feat(editor): preserve Markdown source on copy/cut (#1858)
ProseMirror's default clipboardTextSerializer uses Slice.textBetween,
which flattens every node to its inner text. Copying `## 你好` from the
editor only put `你好` on the clipboard's text/plain channel, so pasting
into VS Code, terminals, or messaging apps lost all Markdown markers.

Add a markdown-copy extension symmetric to the existing markdown-paste:
on copy/cut/drag, route the selected Slice through editor.markdown.serialize
to write the Markdown source. The text/html channel is left at ProseMirror's
default so pasting back into another ProseMirror editor still preserves
exact node structure via data-pm-slice.

Registered for both editable and readonly modes — users frequently copy
from rendered comments/issue descriptions.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:47:20 +08:00
Bohan Jiang
cfa38df97b feat(quick-create): gate on daemon CLI version with pre-check + server enforcement (#1857)
* fix(quick-create): bound dialog height + scroll editor when content overflows

Pasting a screenshot into the agent-create prompt expanded the editor
unbounded, which dragged DialogContent past the viewport since the agent
mode className had no max-height. Manual mode was unaffected because
manualDialogContentClass pins `!h-96`.

- Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays
  `!max-w-xl`); short prompts still render compact, tall content stops
  at 80% of the viewport.
- Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto`
  so it absorbs the remaining vertical space inside the now-bounded
  DialogContent and scrolls internally instead of pushing the dialog.

* feat(quick-create): gate on daemon CLI version with pre-check + server enforcement

The agent-create flow depends on multica CLI behavior introduced in
v0.2.20 (URL attachment handling, no-retry semantics on
`multica issue create` failure — see PR #1851 / MUL-1496). Older
daemons either double-create issues on partial CLI failures or
mishandle pasted screenshot URLs. Per J's review on MUL-1496, gate
the flow at two layers — frontend pre-check for fast feedback,
server re-check as the trust boundary, both fail-closed on
missing/unparsable versions.

Server:
- New MinQuickCreateCLIVersion + CheckMinCLIVersion helper in
  pkg/agent (with sentinel errors for missing vs too-old).
- QuickCreateIssue handler reads runtime metadata.cli_version and
  returns a stable 422 { code: "daemon_version_unsupported",
  current_version, min_version, runtime_id } before enqueuing.
- The check runs after the existing online + ownership validation,
  so all rejections surface uniformly through the modal's existing
  error path.

Frontend:
- New @multica/core/runtimes/cli-version with the min version
  constant, parser, and runtime-metadata reader (tiny semver, no
  new lib dep).
- AgentCreatePanel resolves the selected agent's runtime, runs the
  same check, shows an inline amber notice below the agent picker
  when missing/too old, and disables the Create button.
- Submit handler also catches the server's 422 (defensive race —
  runtime can re-register between pre-check and submit) and
  surfaces the same wording in the error row.

Switching to manual create remains a clean escape hatch — manual
mode doesn't talk to a daemon at all, so an outdated CLI doesn't
block the user from filing the issue.
2026-04-29 18:44:19 +08:00
Naiyuan Qing
4ad0a0b847 feat(chat): presence v4 — status pill, failure bubble, elapsed timing (#1856)
A complete UX upgrade for chat sending → receiving → recovering.

* StatusPill replaces the orphan spinner — stage-aware copy
  ("Reading files · 12s", "Searching the web · 14s", "Typing · 24s"),
  shimmer text, monotonic timer, derived effective status, > 60s
  warning tone, > 5min cancel button.

* WS writethrough on task:queued / task:dispatch / task:cancelled so
  pendingTask cache stays in sync with the daemon state machine without
  invalidate-refetch latency. broadcastTaskDispatch now includes
  chat_session_id when the task is for a chat session — the existing
  payload only carried it on the generic task: events, leaving the pill
  stuck at "Queued" until completion.

* Failure fallback — FailTask writes a chat_message tagged with
  failure_reason (mirrors the issue path's system comment, gated on
  retried==nil). Front-end renders an inline note ("Connection failed",
  with a Show details collapsible) instead of the previous black hole.

* Elapsed timing — chat_message.elapsed_ms persists task.completed_at -
  task.created_at on success/failure rows. UI shows "Replied in 38s" /
  "Failed after 12s" beneath assistant bubbles. Format helper shared
  between StatusPill and the persisted caption so the live timer and
  final reading never disagree.

* Optimistic burst rebalanced — pendingTask seed + created_at moved
  before the HTTP roundtrip so the pill appears the instant the user
  hits send; handleStop is fire-and-forget so cancel feels immediate
  (server confirmation arrives via task:cancelled WS).

* Presence integration — chat avatars use ActorAvatar (status dot +
  hover card); OfflineBanner above the input on offline/unstable;
  SessionDropdown shows per-row in-flight/unread pip plus a
  cross-session aggregate pip on the closed trigger.

* Editor blur on send so the caret stops competing with the StatusPill
  / streaming reply for the user's attention.

* Chat panel isOpen now persists globally; defaults to OPEN for new
  users (storage key absence) so the feature is discoverable. Existing
  users' prior choice is respected.

* DB: migrations 062 (failure_reason) + 063 (elapsed_ms), both
  ADD COLUMN NULL — fast, non-blocking, backwards compatible.

* WS: task:failed chat path now invalidates chatKeys.messages — fixes
  a pre-existing bug where the failure bubble required a page refresh
  to appear.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:29:46 +08:00
Bohan Jiang
1fd583ef65 docs(changelog): publish v0.2.20 release notes (#1855)
* docs(changelog): publish v0.2.20 release notes

* docs(changelog): trim v0.2.20 entry and rename headline feature
2026-04-29 18:29:30 +08:00
Bohan Jiang
286ecf04b1 feat(daemon): add WebSocket heartbeat with HTTP fallback
Adds daemon WebSocket heartbeat acknowledgements while preserving HTTP heartbeat fallback and HTTP task claim/result paths. Keeps old daemon compatibility and task wakeup behavior intact.
2026-04-29 17:17:55 +08:00
Bohan Jiang
bd82607645 fix(execenv): default-disable Codex native multi-agent in per-task config (#1845)
* fix(execenv): default-disable Codex native multi-agent in per-task config

Recent Codex app-server releases enable features.multi_agent by default,
exposing spawn_agent / wait / close_agent tools that let a parent thread
spawn nested subagents. The daemon currently models only the parent thread,
so the parent's turn/completed is treated as task completion even when
spawned children are still running — leading to premature task completion
and dropped child output.

Disable features.multi_agent by default in the per-task CODEX_HOME/config.toml
so Multica's task lifecycle is the only orchestration layer in play. Strip
both the dotted-key form (features.multi_agent) at TOML root and the
multi_agent key inside a [features] table; siblings and unrelated tables
are preserved. Honor MULTICA_CODEX_MULTI_AGENT=1 as an opt-out for users
who explicitly want Codex native subagents inside a Multica task.

The user's global ~/.codex/config.toml is never modified — only the daemon's
isolated per-task copy.

Also widen managedBlockRe to consume `\n*` rather than `\n?` so reruns
don't accumulate blank lines when both the sandbox and multi-agent managed
blocks coexist.

* fix(execenv): inject managed multi_agent inside existing [features] table

Per PR review (codex_multi_agent.go:77-83 vs :112-115): when the user's
config.toml already has a top-level `[features]` table, writing
`features.multi_agent = false` at the TOML root implicitly redefines the
same `features` table. The strict TOML parser used by Codex (`toml-rs`)
rejects that with `table 'features' already exists`, so Codex would fail
to load the per-task config and refuse to start the thread. Verified the
strict-parser failure with pelletier/go-toml/v2; the previous
BurntSushi/toml-based regression test was permissive enough to miss it.

Detect a root-level `[features]` header and place the managed block
inside that table (`multi_agent = false` with marker comments). When no
such header exists, keep the existing root-level dotted-key form. The
managed-block regex matches both layouts so reruns and layout
transitions stay idempotent. A `[features.experimental]` sub-table
without a bare `[features]` header still uses the root dotted-key form,
which is spec-valid (no explicit redefinition).

Tests now use pelletier/go-toml/v2 to actually parse the output and
assert features.multi_agent decodes to false; the regression case from
the PR review is covered explicitly.

* fix(execenv): recognize feature table header variants

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 17:17:09 +08:00
devv-eve
365e84b920 fix(execenv): prefer stdin for formatted comment replies (#1851)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
2026-04-29 17:12:04 +08:00
Bohan Jiang
86e7de3e41 feat(server/auth): cache auth token lookups in Redis with 10m TTL
* feat(server/auth): cache PAT lookups in Redis with 60s TTL

Personal access tokens used to hit Postgres on every request: a SELECT
to resolve token_hash → user_id, plus a fire-and-forget UPDATE of
last_used_at. For a CLI / daemon making many requests per second this
is wasted DB load — the token is the same and the answer hasn't changed.

Add a Redis-backed cache (auth.PATCache) keyed by token hash, TTL 60s:

- On cache hit, the auth middleware skips both the SELECT and the
  last_used_at UPDATE. last_used_at is now refreshed at most once per
  TTL window per token, not per request.
- On cache miss the middleware falls back to today's behavior: query
  Postgres, populate the cache, async-update last_used_at.
- On revoke, the handler invalidates the cache entry so revocation
  takes effect immediately rather than waiting for the TTL to expire.
  This required changing RevokePersonalAccessToken from :exec to :one
  RETURNING token_hash.

The cache is nil-safe: when REDIS_URL isn't configured, NewPATCache
returns nil and the middleware degrades to today's always-hit-DB
behavior. JWT validation is untouched (already DB-free).

Tested with REDIS_TEST_URL — same gating pattern the rest of the
suite uses for Redis-backed tests. New tests cover nil-safety, set/
get/invalidate, TTL, and the middleware short-circuit on cache hit.

* fix(server/auth): clamp PAT cache TTL to token's remaining lifetime

GPT-Boy review caught: a PAT expiring in <60s would still be cached
for the full PATCacheTTL window, so the token could continue passing
auth on cache hit for up to ~60s after its expires_at. The DB query
filters expired tokens (revoked = FALSE AND expires_at > now()), but
that filter never ran on a cache hit.

Make Set take an explicit ttl, and add TTLForExpiry to compute it:
  - no expires_at      → full PATCacheTTL
  - expires_at far     → full PATCacheTTL
  - expires_at <60s    → time until expiry
  - already expired    → 0, Set skips caching (TOCTOU defense between
                         the SELECT and the Set, since the SELECT
                         already filters expired rows)

Regression test pins the clamp behavior end-to-end against Redis.

* feat(server/auth): cache daemon-token + PAT lookups in DaemonAuth, bump TTL to 10m

Daemon /api/daemon/* requests (heartbeat, claim task) hit DaemonAuth
which previously did its own GetDaemonTokenByHash on every request and
*also* duplicated the PAT lookup on the mul_ fallback — bypassing the
cache added in 1cdd674c. Today's daemons authenticate via mul_ PATs
(mdt_ minting isn't wired up yet), so the duplicate PAT path is the one
that actually matters for hot-path DB load.

Three changes:

1. New auth.DaemonTokenCache mirrors PATCache for the mdt_ path
   (key = mul:auth:daemon:<sha256>, JSON value = {workspace_id, daemon_id}).
   Forward-looking infrastructure for when daemon tokens get minted; the
   middleware short-circuits the DB SELECT on cache hit. TTL clamped to
   the token's expires_at via the shared TTLForExpiry helper.

2. DaemonAuth now also consults PATCache on its mul_ fallback, sharing
   the same cache as the regular Auth middleware. A daemon making 4 hb/min
   collapses from 4 GetPersonalAccessTokenByHash + 4 last_used_at writes
   per minute to ~1 of each per AuthCacheTTL window (~10 minutes).

3. Rename PATCacheTTL → AuthCacheTTL and bump from 60s to 10 minutes.
   The constant is now shared between PAT and daemon caches; 10m matches
   the user-requested longer TTL for further DB write reduction. Revoke
   latency on the happy path is still instant via active invalidation;
   the worst-case (Redis Del miss / direct-DB revoke) grows from ~60s to
   ~10m.

Tests cover nil-safety, set/get/invalidate, TTL, clamped TTL on near-
expiry tokens, and the middleware short-circuit for both cache paths
(mdt_ via DaemonTokenCache, mul_ fallback via PATCache).

* feat(server/auth): cache PAT lookups on the WebSocket auth path

The third place a PAT is resolved — patResolver.ResolveToken used by
realtime.HandleWebSocket — was still hitting Postgres on every /ws
auth and firing an unconditional last_used_at UPDATE, bypassing the
cache added in 1cdd674c. Wire it through the same shared PATCache so
revoking a token through any path (Auth middleware, DaemonAuth PAT
fallback, or WS auth) hits all three caches with one Invalidate.

Also leaves a comment on DeleteDaemonTokensByWorkspaceAndDaemon —
the query has no caller today, but a future deregister/rotate flow
must remember to call DaemonTokenCache.Invalidate(hash) for each
deleted row, otherwise deleted daemon tokens stay valid until TTL.
2026-04-29 17:07:54 +08:00
Bohan Jiang
936ccce8fa fix(comments): unescape \n in agent task-completion output (#1850)
PR #1744 fixed literal `\n\n` rendering for the CLI surfaces (`issue
create / update --description`, `issue comment add --content`) but the
agent-completion path bypasses the CLI entirely: the daemon POSTs the
agent's stdout to `/api/daemon/tasks/:id/complete`, and `TaskService.
CompleteTask` writes `payload.Output` straight into `createAgentComment`
and `CreateChatMessage` without decoding. Models (e.g. Codex) routinely
emit Python/JSON-style `\n` literals in their final output, which then
land in the DB as the 4-char escape sequence and render as one wall of
text in the issue/chat panel — exactly the bug report in #1820.

- Move `unescapeFlagText` from `server/cmd/multica/cmd_issue.go` to
  `server/internal/util/text.go` as `UnescapeBackslashEscapes` so the
  CLI and the service layer share one implementation. The full
  contract-boundary test suite moves with it.
- Apply `UnescapeBackslashEscapes` to `payload.Output` before it
  reaches `createAgentComment` and `CreateChatMessage` in
  `TaskService.CompleteTask`. Same `\n / \r / \t / \\` decoding as the
  CLI; other escape sequences (`\d`, `\w`, `\u`, etc.) pass through
  verbatim so regex/format strings in agent output survive.

Closes #1820
2026-04-29 17:05:17 +08:00
Bohan Jiang
49ccd22027 fix(cli,quick-create): no duplicate issue when --attachment fails post-create (#1849)
Two coordinated fixes for a quick-create case where the agent ended up
creating duplicate issues. Repro: user pasted an image into the
quick-create prompt; the front-end uploaded it and embedded the URL as
markdown in the user input; the agent saw the URL, assumed it was an
attachment, and ran `multica issue create … --attachment "https://…"`.
The CLI POSTed the issue first, then failed to read the URL as a file
(`os.ReadFile("https://…")`) and exited 1. The agent treated exit 1 as
"create failed" and retried — but the first issue already existed, so
the workspace ended up with two of them.

CLI (`server/cmd/multica/cmd_issue.go`):
- `runIssueCreate` pre-validates `--attachment` BEFORE POSTing. URLs are
  warned about and skipped (they are never local files); local-path
  read errors fail before the issue is created so no half-baked issue
  lands. Once the POST succeeds, post-create upload failures only
  print a stderr warning and the issue metadata is still emitted —
  never a non-zero exit, so callers cannot mistake "attachment upload
  hiccup" for "create failed" and retry.
- `runIssueCommentAdd` already uploads attachments BEFORE the comment
  is created, so its failure mode is fine; it just gets the same
  URL-skip behaviour for consistency.

Quick-create prompt (`buildQuickCreatePrompt`):
- Tells the agent NOT to pass `--attachment` for prompt-embedded image
  URLs (they are already part of the description as markdown).
- Hardens the "no retry" rule: even on a non-zero exit, do not retry
  `issue create` — the issue may already exist.
2026-04-29 17:00:41 +08:00
Naiyuan Qing
e66bd593ea feat(web): add editorial 404 page (#1844)
Custom Next.js root not-found.tsx with cream/ink/terracotta editorial
palette and Instrument Serif hero. Replaces the bare default 404 on any
unmatched URL. Single CTA back to /, which routes appropriately based
on auth state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:53:01 +08:00
Bohan Jiang
7528022355 fix(quick-create): bound dialog height + scroll editor when content overflows (#1847)
Pasting a screenshot into the agent-create prompt expanded the editor
unbounded, which dragged DialogContent past the viewport since the agent
mode className had no max-height. Manual mode was unaffected because
manualDialogContentClass pins `!h-96`.

- Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays
  `!max-w-xl`); short prompts still render compact, tall content stops
  at 80% of the viewport.
- Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto`
  so it absorbs the remaining vertical space inside the now-bounded
  DialogContent and scrolls internally instead of pushing the dialog.
2026-04-29 16:49:56 +08:00
Prince Pal
391a4ecd09 feat: add backend default agent args env vars (#1807)
* feat: add backend default agent args env vars

* docs: document default agent args env vars
2026-04-29 16:49:48 +08:00
Bohan Jiang
54d895a210 fix(execenv): mandate comment-history read on assignment-triggered runs (#1843)
GitHub #1839: when an issue is reassigned from agent A to agent B, B
often only reads the issue body and misses context A added in comments
(e.g. which repo to clone). The assignment-triggered workflow injected
into CLAUDE.md / AGENTS.md said "Read comments for additional context
or human instructions" — vague enough that agents routinely skipped
it. The comment-triggered branch already gives an explicit
`multica issue comment list` invocation, so behavior diverged.

Promote step 3 to a concrete CLI call, mark it mandatory, and surface
the most common failure mode (stale instructions on reassignment) so
the agent recognizes when it matters. Reorder so comments are read
*before* flipping status to `in_progress`, matching how a human would
catch up on a thread before claiming work.
2026-04-29 16:38:27 +08:00
Bohan Jiang
40a984c997 feat(quick-create): default assignee to picker agent when user didn't name one (#1836)
* feat(quick-create): default assignee to picker agent when user didn't name one

The quick-create prompt previously told the agent to OMIT --assignee
when the user's input didn't name a person. That left almost every
quick-created issue unassigned, which doesn't match user intent — the
user opened quick-create with a specific agent picked, so that agent
is the obvious owner.

Both prompt surfaces (BuildPrompt for the dispatched message, plus
the workflow block in injected CLAUDE.md / AGENTS.md) now instruct
the agent: if the input doesn't name an assignee, pass
`--assignee "<your name>"`. The picker agent's name is interpolated
into the prompt at task-build time so the agent has a literal value
to use rather than guessing its own name. The "explicitly named
assignee → resolve via members" branch is unchanged.

* refactor(execenv): drop duplicated quick-create field rules from CLAUDE.md/AGENTS.md

The quick-create field rules (title / description / priority / assignee
fallback / project / status) lived in two places — the per-turn user
message built by BuildPrompt, and the workflow block injected into
CLAUDE.md / AGENTS.md by buildMetaSkillContent. Same content, two
sources, easy to update one and forget the other (the assignee-default
change in this PR had to touch both).

Quick-create is one-shot, so the per-turn user message is always
present and is the natural single source of truth. The injected
file's quick-create section now keeps only the hard guardrails:
"do exactly one issue create, no issue get / status / comment add,
exit on CLI error". Those guardrails stay in BOTH surfaces because
they're the safety net for providers that don't propagate the user
message into resumed-session context.

renderQuickCreateContext (issue_context.md) was already
guardrails-only — no change needed there.
2026-04-29 16:08:08 +08:00
Bohan Jiang
9ccaf18479 fix(comment): don't inherit parent @mentions from agent-authored roots (#1833)
* fix(comment): don't inherit parent @mentions when parent author is an agent

When an agent posts a comment that @mentions another agent (typically a
one-shot delegation, e.g. a PR-completion comment that asks a reviewer
agent to review), member follow-up replies in the same thread were
auto-inheriting that mention and re-triggering the reviewer on every
plain question. Same root cause: the inheritance branch only required
the reply to have no mentions, not that the parent was member-authored.

Tighten the guard: only inherit when the parent (thread root) is
authored by a member. Member-rooted threads still inherit so a member
who started by @mentioning an agent can keep replying without re-typing.
Agent-authored roots are treated as one-shot — explicit @mentions in
later comments still trigger normally.

Extracted the decision into shouldInheritParentMentions for direct unit
testing, and added an end-to-end regression
(TestMemberReplyToAgentRootDoesNotInheritParentMentions) that reproduces
MUL-1535: J posts a PR completion @mentioning Reviewer; a member's
plain follow-up must not re-enqueue Reviewer.

* chore(comment): gofmt trigger_test.go
2026-04-29 15:54:24 +08:00
Bohan Jiang
866b901943 fix(desktop): use themed Toaster wrapper instead of bare sonner (#1835)
#1831 fixed the Toaster wrapper to follow next-themes' resolvedTheme,
but the desktop renderer was importing `Toaster` directly from `sonner`
and never going through the wrapper. So the success toast still rendered
light on a dark UI. Switch the import to `@multica/ui/components/ui/sonner`
to match the web app and pick up the theme + icon overrides.
2026-04-29 15:53:51 +08:00
Bohan Jiang
9baa72cc68 fix: polish quick-create UX (kind labeling, dark toast, placeholder) (#1831)
* fix: polish quick-create UX (kind labeling, dark toast, placeholder)

Three small fixes shaken out from using the agent-create flow:

- AgentTaskResponse now carries a `kind` discriminator
  ("comment" | "autopilot" | "chat" | "quick_create" | "direct"), computed
  from the existing FK shape with no extra DB access. The Activity row
  uses it to label quick-create tasks as "Creating issue" instead of
  falling through to the generic "Untracked" — once the agent finishes
  and the new issue is linked, the row transitions to the normal
  identifier+title display.

- Sonner Toaster reads `resolvedTheme` instead of `theme`, so toasts
  follow the actual dark/light state. Forwarding "system" let sonner
  pick its own answer from `prefers-color-scheme`, which in the Electron
  renderer can disagree with next-themes' `html.dark` class — the toast
  rendered light on a dark UI.

- Agent-create placeholder rephrased to a more conversational example
  with a project reference: "let Bohan fix the inbox loading slowness
  in the Web project". Drops the priority hint (priority isn't widely
  used) and matches how people actually instruct the agent.

* fix(quick-create): link new issue back to task on completion

Addresses the review on PR #1831: completed quick-create tasks were
left with issue_id=NULL forever, so the activity row stayed on
"Creating issue" instead of transitioning to the normal MUL-XXX +
title rendering once the agent finished.

- Server: notifyQuickCreateCompleted now writes the resolved issue id
  back to agent_task_queue.issue_id via a new LinkTaskToIssue query
  (guarded by `issue_id IS NULL` so it only ever fills the unset
  quick-create case). Best-effort: a write failure logs but doesn't
  block the inbox notification.
- Frontend: defensive wording fallback — kind=quick_create rows in
  terminal status (completed/failed/cancelled) now render as
  "Quick create" instead of the active "Creating issue" label,
  covering rows whose link write failed or whose agent never
  produced an issue at all.
2026-04-29 15:40:59 +08:00
Bohan Jiang
576304519b docs(execenv): expose label/subscriber CLI + complete create/update flag list (#1830)
The agent-facing CLAUDE.md/AGENTS.md injected by InjectRuntimeConfig was
missing every doorway to non-core issue properties:

- `multica issue label list/add/remove` — the only way to label a newly
  created issue from the agent. Without it, agents either give up
  ("no command for that, please add it manually") or hallucinate flag
  names like `multica issue create --label foo` and fail.
- `multica issue subscriber list/add/remove` — same story for the
  subscribe-on-behalf flow.
- `multica label list/create` — agents need to discover existing label
  ids before they can attach one (we don't auto-create labels here).
- `issue create` flag list dropped `--project`, `--due-date`,
  `--attachment` even though the CLI has supported them for a while.
- `issue update` flag list dropped `--status`, `--assignee`,
  `--project`, `--due-date`, `--parent`, leaving agents thinking they
  could only edit title/description/priority via update.

Also splits `issue status` from `issue update` in the doc so the agent
sees the shortcut, and notes the `issue create` body intentionally
does NOT accept labels/subscribers (use the post-create commands).
2026-04-29 15:29:03 +08:00
Naiyuan Qing
f0a3f5ddeb chore(docs): remove shipped agent-runtime redesign + workspace audit docs (#1829)
These were transitional handoff/design docs that fulfilled their purpose:

- docs/agent-runtime-status-redesign.md (802 lines) — design + plan for
  PR #1794 (presence v3, availability + last-task split). Shipped.
- docs/agent-runtime-ui-design-brief.md (530 lines) — paired designer
  brief for the same redesign. Shipped.
- HANDOFF_ARCHITECTURE_AUDIT.md (383 lines) — 4-task audit packaged for
  the workspace URL refactor (PR #1138/#1141). The URL refactor itself
  shipped; the other tasks are either resolved or live in code as the
  source of truth. File:line snapshots inside have rotted.

Follows the precedent set by #1504 (chore(docs): remove shipped plan and
proposal docs). Code is the source of truth once the work is in.
2026-04-29 15:16:54 +08:00
Bohan Jiang
22136a55fc fix(server/heartbeat): split auth_ms into decode/runtime_lookup/workspace_check + auth_path (#1822)
Prod slow-log on the deployed v0.2.17 fix shows total_ms=4012,
auth_ms=4010, update_ms=1, all skill stages = 0 — meaning the bottleneck
on /api/daemon/heartbeat is now the auth section, not the Redis claim
path. To pinpoint which sub-stage dominates, decompose auth_ms into:

- decode_ms        — JSON body decode
- runtime_lookup_ms — Queries.GetAgentRuntime (PG PK select)
- workspace_check_ms — requireDaemonWorkspaceAccess (string compare for
                       daemon-token, requireWorkspaceMember for PAT/JWT)

Also add auth_path ("daemon_token" | "pat" | "jwt") set by DaemonAuth
middleware so slow-logs disambiguate which token kind was used. PAT/JWT
takes an extra DB round-trip via requireWorkspaceMember and is a
candidate cause of long auth tails on daemons that haven't migrated to
mdt_ tokens.

The handler keeps the same external behavior; the change inlines and
instruments requireDaemonRuntimeAccess in DaemonHeartbeat only — other
callers of the helper are untouched. logHeartbeatEndpointSlow gains the
new fields.

Existing heartbeat tests pass; the slow-probe test output now shows the
new auth_path / decode_ms / runtime_lookup_ms / workspace_check_ms
fields populated.
2026-04-29 15:00:00 +08:00
Bohan Jiang
375534573c feat(editor): rank mention dropdown by per-device recency (#1825)
Members and agents previously appeared in fixed buckets (members first,
then agents) following raw cache order. Replace that with a single ranked
list driven by the user's most recent mentions on this device, with an
alphabetical fallback for never-mentioned targets. Recency is stored in
localStorage per workspace and lazy-pruned at 200 entries.
2026-04-29 14:58:47 +08:00
Bohan Jiang
2a59236575 refactor(create-issue): unify agent/manual modes under one Dialog shell (#1826)
Recasts Quick/Advanced as Agent/Manual and lets users flip between modes
in-place from a footer switch button instead of a separate Advanced
shortcut. The two old modal types now route through one CreateIssueDialog
shell that owns the single <Dialog> and <DialogContent> — only the inner
panel body swaps on mode change, so the Portal/Backdrop/Popup stay
mounted and the switch is instant (no close→open animation flash).

Mode preference is persisted globally in localStorage via a small
useCreateModeStore, so the `c` shortcut always opens whichever mode the
user last used (or switched to). Carry payload (description / agent /
prompt) hands off through the shell's local state plus the existing
issue-draft store, so nothing the user typed is lost across switches.

Also drops the Shift+C → manual branch — `c` is now mode-agnostic and
the in-modal switch covers the same intent without users having to
remember a second shortcut.

Visible labels: "Quick create" → "Create with agent",
"New issue" → "Create manually".
2026-04-29 14:57:36 +08:00
Bohan Jiang
415060e6be fix(quick-create): unstick queued tasks (workspace resolution + WS wakeup) (#1827)
Two related bugs that combined to leave every quick-create task in
'queued' from the user's POV:

1. ResolveTaskWorkspaceID returned "" for any task whose
   issue_id / chat_session_id / autopilot_run_id were all NULL —
   exactly the shape of a quick-create task. That made
   requireDaemonTaskAccess 404 on the daemon's /start, /progress,
   /complete, /fail endpoints, and silently dropped task:dispatch /
   task:completed broadcasts. Even when the claim itself succeeded,
   the daemon couldn't drive the task forward, so it stalled and
   eventually got swept back. Read the workspace from the
   QuickCreateContext JSONB so every downstream lookup works.

2. EnqueueQuickCreateTask never called notifyTaskAvailable, so the
   daemon WS wakeup never fired for quick-create. The 30 s poll
   fallback would eventually pick the task up, but combined with #1
   that meant the task spent the bulk of its life looking like
   "queued, never triggered". Match the chat / issue / autopilot
   enqueue paths and signal the wakeup.
2026-04-29 14:57:08 +08:00
Naiyuan Qing
f745a3bbbe feat(agent): presence v3 + execution log + trigger summary (#1823)
* refactor(views): migrate agent/runtime/skill lists to TanStack DataTable

Replace the per-page CSS Grid + minmax(min, fr) + sticky-first-col + truncate
implementation with a TanStack Table backend rendered through a Dice UI-style
DataTable shell. Column widths are now px-based via column.size, so cells
no longer shrink or auto-truncate as the viewport narrows; when the sum of
columns exceeds the viewport, the container scrolls horizontally instead.

- Add @tanstack/react-table to the catalog (8.21.3) and wire it into
  packages/ui (dep) and packages/views (peerDep).
- packages/ui: new DataTable + DataTableColumnHeader + lib/data-table.ts
  (getColumnPinningStyle), adapted from Dice UI's registry. The shell
  renders <table> directly (skipping shadcn's <Table> wrapper) so its own
  outer overflow controls both axes — no nested overflow conflicts.
- packages/views: each list now declares ColumnDef[] with explicit
  cell renderers. Row click navigates to detail via onRowClick (instead of
  wrapping <tr> in <a>, which is invalid HTML); kebab dropdowns
  stopPropagation so they don't trigger the row navigation.
- Drop the previous AGENT_LIST_GRID / GRID_WITH_OWNER / ROW_GRID
  templates and the sticky-first-col / subgrid mechanics that came with
  them. agent-list-item.tsx is removed; runtime-list.tsx and
  skills-page.tsx are trimmed to thin wrappers.

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

* feat(agent): cap description at 255 chars (db + api + ui)

Symmetric enforcement across DB, server, and UI:

- Migration 060: pre-flight truncate of any oversize rows, then ADD
  CONSTRAINT NOT VALID + VALIDATE CONSTRAINT so the new check doesn't
  block writes during validation.
- Server handler validates utf8.RuneCountInString on Create/Update and
  rejects over-limit input with 400.
- Front-end gets AGENT_DESCRIPTION_MAX_LENGTH in core/agents/constants
  (single source of truth shared by the create dialog + edit modal +
  test suite) and a CharCounter component that warns at 90% and errors
  past the cap.
- Description editor moves from a 288px popover to a roomy modal.
  Editor body is mounted only while the dialog is open, so the local
  draft state is locked in at mount time and never reset by an external
  WS update — the React-recommended replacement for the
  useEffect(reset, [value]) anti-pattern.

Counted in code points everywhere (rune count / spread length /
char_length) so multibyte input agrees across all three layers.

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

* refactor(views): data-table polish across runtime + skill lists

Builds on the DataTable migration in 2be0f287:

- Add ColumnMeta.grow flag — declared via TanStack module augmentation
  in ui/lib/data-table.ts. Columns marked meta.grow skip their inline
  width so fixed table-layout assigns them the leftover container space
  (no spacer column). The Title-grows / others-fixed pattern from
  Linear / GitHub PR rows.
- Authoritative table min-width = sum of column.size, applied to the
  <table> itself (fixed-layout ignores cell-level min-width per spec,
  so the floor has to live on the table).
- Header tightens to h-8 + uppercase + tracking-wider; pinned cells
  switch to opaque bg + group-hover so they cover content scrolling
  beneath them and follow row hover state.
- Toolbar slot removed from DataTable (callers wrap the toolbar
  themselves now — keeps DataTable single-purpose).

Also: hover-card popup stops contextmenu / auxclick / dblclick from
bubbling out (in addition to click). Stops the popup from triggering
ancestor handlers (e.g. issue list rows) on right-click / middle-click
without breaking Base UI's outside-click dismiss, which listens to
pointerdown — pointerdown is deliberately NOT stopped.

Runtime + skill list pages updated to use the new sizing model.

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

* refactor(agent): drop LastTaskState, introduce 3-state Workload

Continues the presence-model rework started in #1794 / #1798.

The previous LastTaskState union (running / completed / failed /
cancelled / idle) carried historical outcome at the list level — a
runtime-healthy agent whose last task failed showed a sticky red dot
indistinguishable from a daemon-dead agent.

New model: presence is two orthogonal "right-now" dimensions:

  AgentAvailability — runtime reachability only (online / unstable /
                      offline). Drives the dot colour everywhere.
  Workload          — current load (working / queued / idle). Three
                      states, never historical. Failure / completion /
                      cancellation are surfaced via Recent Work + Inbox,
                      not list-level state.

`queued` (= nothing running, ≥1 queued) is an honest "stuck on offline
runtime" signal. To avoid amber flashes during the brief enqueue→claim
race on healthy runtimes, the queued chip composes with availability:
muted on online, warning amber otherwise.

Activity tab cleanup that follows from the new model:
  - failureReasonLabel relocated from agents/presence.ts to
    tabs/task-failure.ts (presence no longer owns historical state).
  - Recent Work paginates (5 initial, +20 per "Show more"); chat-session
    tasks are filtered out of every Agent-scoped surface to keep
    "team work" separate from private chat.
  - Agents page drops the lastTaskFilter chip group; users find broken
    agents via Inbox / Recent Work, not a list-level filter.

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

* feat(task): trigger summary snapshot + task:queued lifecycle event

Two task-lifecycle improvements that ship together because they share
the same enqueue/retry hot paths and changes interleave inside task.go:

1. trigger_summary snapshot (migration 061)

   New nullable column on agent_task_queue. Comment-triggered tasks
   snapshot the comment content; autopilot tasks snapshot the run title.
   Truncated to 200 runes via strings.Builder so multibyte input counts
   correctly without O(N²) concatenation. Snapshot survives source
   edits/deletes — every task row self-describes across surfaces (issue
   detail Execution log, agent activity tooltip, inbox) without joining
   back to the originating row.

   Retry rows inherit the parent's snapshot (CreateRetryTask SELECT) so
   the description stays meaningful across attempts. The UI is
   responsible for stacking "Retry #N" context on top.

2. task:queued WS event

   New protocol event covering the ∅ → queued transition. Front-end
   types/events.ts registers it; use-realtime-sync's task: prefix path
   already invalidates task caches via onAny, so old clients without
   this exact-match subscription still refresh correctly. Specific
   subscribers (sticky banner) get sub-second updates instead of
   waiting for daemon claim.

   Retry path now broadcasts task:queued (not task:dispatch) — same
   status transition shape as enqueue, so all "new task created" paths
   agree on one event type.

   Ordering: broadcastTaskEvent runs *before* notifyTaskAvailable so
   the queued event is published into the WS bus before the daemon is
   poked. Without this, a fast daemon could claim and emit task:dispatch
   over the wire before the in-process queued broadcast fan-out reached
   clients — race window is tiny but unsafe-by-construction.

   Per-agent task list (agentTasksKeys.all) and per-issue task list
   (["issues","tasks"]) added to the task: invalidation set so Activity
   tab Recent Work and the Execution log section stay fresh.

Type contracts: AgentTask gains parent_task_id / attempt /
trigger_comment_id (already returned by the API, just missing from TS)
plus the new trigger_summary field.

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

* feat(issue): ExecutionLogSection — unified active+past runs panel

Replaces two pieces:
  - the click-to-expand timeline that lived inside AgentLiveCard
  - the standalone TaskRunHistory below the main content

with a single right-panel section that lists every agent run for the
issue. Active runs sit at the top (always visible when present); past
runs collapse behind a "Show past runs (N)" toggle, sorted failed →
cancelled → completed within group.

Active rows show the trigger summary, status + relative time, and
Cancel / Transcript actions on hover (gradient backdrop fades the
status text rather than hard-clipping). Past rows show the same
shape minus Cancel.

Retry tasks prepend "Retry #N · " to the inherited summary so they're
distinguishable from their parent (which would otherwise share the
exact same trigger text).

Cache key registered as issueKeys.tasks(issueId); the global
useRealtimeSync task: prefix path already invalidates ["issues","tasks"]
on every task lifecycle event, so the section stays fresh without
local WS subscriptions.

AgentLiveCard slims down to a header-only "agent is working" sticky
banner — keeps the at-a-glance "is anyone working on this right now"
signal and the Stop / Transcript actions, drops the inline timeline
that ExecutionLogSection now owns. Subscribes to both task:queued and
task:dispatch so retries (which only emit queued) land in the banner
without waiting for daemon claim.

issue-detail mounts ExecutionLogSection in the right panel and removes
the now-defunct TaskRunHistory call site.

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-29 14:50:58 +08:00
281 changed files with 17746 additions and 5187 deletions

View File

@@ -174,6 +174,22 @@ Daemon behavior is configured via flags or environment variables:
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
| GC enabled | — | `MULTICA_GC_ENABLED` | `true` (set `false`/`0` to disable) |
| GC scan interval | — | `MULTICA_GC_INTERVAL` | `1h` |
| GC TTL (done/cancelled issues) | — | `MULTICA_GC_TTL` | `24h` |
| GC orphan TTL (no `.gc_meta.json`) | — | `MULTICA_GC_ORPHAN_TTL` | `72h` |
| GC artifact TTL (open issues) | — | `MULTICA_GC_ARTIFACT_TTL` | `12h` (set `0` to disable) |
| GC artifact patterns | — | `MULTICA_GC_ARTIFACT_PATTERNS` | `node_modules,.next,.turbo` |
#### Workspace garbage collection
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
Agent-specific overrides:
@@ -181,8 +197,10 @@ Agent-specific overrides:
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
@@ -200,6 +218,8 @@ Agent-specific overrides:
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
### Self-Hosted Server
When connecting to a self-hosted Multica instance, the easiest approach is:

View File

@@ -1,383 +0,0 @@
# Architecture Audit — Workspace & Realtime Cache
> 基于代码审计整理的 4 个任务。优先级P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
---
## 任务 1 — [P0] 空闲后列表数据陈旧
**关联 issue**[#951](https://github.com/multica-ai/multica/issues/951)
### 问题
用户登录后静置一段时间Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
### 根因
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
- `packages/core/query-client.ts:7``staleTime: Infinity`cache 永不主动过期
- `packages/core/query-client.ts:9``refetchOnWindowFocus: false`tab 重新获得焦点也不 refetch
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
但 WS 层存在一个**不对称**
- **服务端**`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait会清理死连接
- **客户端**`packages/core/api/ws-client.ts`142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
1. 浏览器 `readyState` 仍是 `OPEN`
2. `onclose` 不触发
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
4. `packages/core/realtime/use-realtime-sync.ts:462-487``onReconnect` 全量 invalidate 不跑
5. 期间的 WS 事件进黑洞
6. cache 保持旧快照
### 复现
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
**方法 A推荐最接近真实场景**macOS 用 pfctl 丢包
```bash
# 假设后端在 8080
sudo pfctl -E
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
# 观察:
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
# - Network 里 WS 连接仍显示 Pending / 101
# 用另一个账号/CLI 创建一个 issue
# 回到原客户端: 列表不更新
# 登出再登入: 列表恢复完整
sudo pfctl -d # 解除
```
**方法 B不动网络**:临时修改代码,在 `packages/core/api/ws-client.ts:52``onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
### 修复方案(三个选项,推荐 C
#### 选项 A — 浏览器端心跳探活(治本,改动大)
`ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
- 优点:从根本上解决半开问题
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
#### 选项 B — Page Visibility API 触发 invalidate治标改动小
`packages/core/platform/core-provider.tsx``visibilitychange` 监听tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab
- 缺点treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
#### 选项 C — **A + B 组合**(推荐)
- 短期上 B立刻止血
- 中期上 A把 cache 新鲜度从"只信 WS"改成"WS 是优化Visibility 是兜底"
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min作为第三层保险
### 改动范围
| 方案 | 文件 | 改动规模 |
|---|---|---|
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
### 验证
修完之后:
1. 跑方法 A 复现流程,确认数据不再丢失
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
---
## 任务 2 — [P1] Workspace 不在 URL 路径中
**关联 issue**MUL-723slug 不在 URL、MUL-43切换 workspace 报错、MUL-509手机端无法切换
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR。交接时请让执行人以具体症状为准。
### 问题
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载URL 里没有 workspace 信息。所有路径都是 `/issues``/issues/:id` 这种 workspace-agnostic 的。
### 根因
**数据库和 API 已经支持 slug**
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
**但前端路由和导航层没用它**
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
**workspace 切换只靠 sidebar UI**`packages/views/layout/app-sidebar.tsx:284-286`
```tsx
if (ws.id !== workspace?.id) {
push("/issues"); // 硬跳 /issuesworkspace-implicit
switchWorkspace(ws); // 然后改 store
}
```
这种设计使得:
- 手机端因为没 sidebar UI也没 URL 层切换入口,**完全切不了 workspace**MUL-509
-`/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue或找不到报错MUL-43 系列)
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
### 复现
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
2. **MUL-43**
- 加入两个 workspace A 和 B
- 在 A 中打开某个 issue `/issues/abc123`
- 切到 BURL 不变 → 访问失败 / 显示错数据
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换UI 不显示 sidebar 触发器或触发器无法切)
### 修复方案(三个选项,推荐 A
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
所有路径加上 workspace slug 前缀。例如 `/issues/abc123``/ws/my-team/issues/abc123`
**要改的地方**
1. **Web 路由目录结构**`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`~25 个文件)
2. **Desktop 路由**`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
3. **Navigation 适配器**
- `apps/web/platform/navigation.tsx``push(path)` 内部前置 `/ws/${workspace.slug}``pathname` 读取时去掉前缀
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
4. **Sidebar 切换逻辑**`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
5. **服务端中间件**`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑header 继续作为 fallback迁移期兼容
**预计改动**~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
**不改也能工作的部分**
- `packages/core/api/client.ts` — 仍旧走 header不用改
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
**风险**
- 旧的 bookmark URL 失效(如果产品还没正式 ship问题不大
- E2E 测试需要更新所有 URL 断言
#### 选项 B — `?ws=slug` query param折中
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件URL 丑但向后兼容。推荐度低于 A。
#### 选项 C — 只修症状不动架构
`switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因技术债越攒越多。**不推荐**。
### 改动范围(选项 A
| 模块 | 文件数 | 备注 |
|---|---|---|
| Web routes | ~25 | 目录搬迁 |
| Desktop routes | 1 | 路径前缀 |
| Navigation adapters | 2 | 前缀逻辑 |
| Server middleware | 1-2 | slug → ID 解析 |
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
| E2E tests | 20-30 | URL 断言更新 |
---
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
**关联 issue**MUL-43切换报错、MUL-476本地缓存未按 workspace 隔离)
> 同上,这两个编号建议交接时核对症状。
### 问题
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage切换 workspace 后里面仍是上个 workspace 的路径。
### 根因
**`packages/core/navigation/store.ts:15-31`**
```typescript
export const useNavigationStore = create<NavigationState>()(
persist(
(set) => ({
lastPath: "/issues",
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
}),
{
name: "multica_navigation",
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global不是 workspace-aware
partialize: (state) => ({ lastPath: state.lastPath }),
}
)
);
// ← 没有调 registerForWorkspaceRehydration
```
**对比:其他 store 都是正确的**
| Store | 是否 workspace-aware | 是否注册 rehydration |
|---|---|---|
| useNavigationStore | ❌ | ❌ |
| useIssuesScopeStore | ✅ | ✅ |
| useIssueDraftStore | ✅ | ✅ |
| useRecentIssuesStore | ✅ | ✅ |
| useIssueViewStore | ✅ | ✅ |
| myIssuesViewStore | ✅ | ✅ |
| useChatStore | ✅(手动用 wsKey| ✅ |
另外 `packages/core/platform/storage-cleanup.ts:10-19``WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`
**现有的 workaround**`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
### 复现
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
2. 切到 workspace B
3. 观察:如果没有 sidebar 的硬跳 workaround会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B导致 404 或错误
目前因为有硬跳 workaround症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug用户期望记住上次位置
### 修复方案(推荐 Option C组合
**三处改动**
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
3. `packages/core/platform/storage-cleanup.ts:10-19``WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285``push("/issues")` workaround改完之后不再需要
### 改动范围
| 文件 | 改动 |
|---|---|
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
---
## 任务 4 — [P2] Workspace 生命周期副作用散落
**关联 issue**MUL-727创建后闪页、MUL-728删除确认、MUL-820接受邀请不自动切
### 问题
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
- `packages/core/workspace/mutations.ts:7-21``useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand`/issues` 路由开始用新 workspace 渲染
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx``onSuccess` 里先 `switchWorkspace``push`,保证同一个微任务里完成。
**文件**`packages/core/workspace/mutations.ts``packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
**核查结果**`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
**修复**:在 `handleDeleteWorkspace``onConfirm` 成功分支里加 `push("/issues")`
**文件**`packages/views/settings/components/workspace-tab.tsx`(加一行)
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
**核查结果**:有两条路径:
-`/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**accept → switchWorkspace → push("/issues")
-**Sidebar 下拉里的 "Join" 按钮**`packages/views/layout/app-sidebar.tsx:203-209, 321-324`**是错的**:只 invalidate cache不切也不跳
**修复(推荐 Option 2**Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
```tsx
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
{inv.workspace_name}
</DropdownMenuItem>
```
**文件**`packages/views/layout/app-sidebar.tsx`~10 行)
### 复现
| Issue | 步骤 |
|---|---|
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面BUG: 没有自动跳走) |
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去BUG: 不切)|
### 长期架构建议(可选)
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bughook 抽象作为后续迭代。
### 改动范围
| Issue | 文件 | 改动规模 |
|---|---|---|
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
| MUL-728 | workspace-tab.tsx | ~1 行 |
| MUL-820 | app-sidebar.tsx | ~10 行 |
---
## 总览
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|---|---|---|---|---|
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行Option C ~1-2 天 | 低 |
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中影响面大、e2e 要改)|
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
### 建议推进顺序
1. **立刻做**:任务 1 的 Option Bvisibilitychange 触发 invalidate—— 代码最少、收益最明显,能当天止血
2. **同步开始**:任务 3navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
3. **规划立项**:任务 2URL 化)—— 大改造,需要单独开一个 iteration
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR各自 review
### 重要澄清
- **Issue 编号核对**MUL-43 / MUL-476 的编号需要核对一次agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
- **MUL-820 实际状态**`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
### 所有关键代码位置索引
```
packages/core/query-client.ts:7-10 # staleTime: Infinity
packages/core/api/ws-client.ts:1-142 # 客户端 WS无心跳
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
server/internal/middleware/workspace.go:41-46 # wsId resolution
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
```

View File

@@ -36,6 +36,18 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
</p>
## Why "Multica"?
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
## Features
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.

View File

@@ -36,6 +36,18 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</p>
## 为什么叫 "Multica"
Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
这个名字是在向 20 世纪 60 年代具有开创意义的操作系统 Multics 致意。Multics 首创了分时系统让多个用户能够共享同一台机器同时又像各自独占它一样使用。Unix 则是在有意简化 Multics 的基础上诞生的,强调一个用户、一个任务、一种优雅的哲学。
我们认为类似的转折点正在再次出现。几十年来软件团队一直处于一种单线程的工作模式一个工程师处理一个任务一次只专注于一个上下文。AI agents 改变了这个等式。Multica 将"分时"重新带回这个时代,只不过今天在系统中进行多路复用的"用户",既包括人类,也包括自主代理。
在 Multica 中agents 是一级团队成员。它们会被分配 issue汇报进展提出阻塞并交付代码就像人类同事一样。任务分配、活动时间线、任务生命周期以及运行时基础设施Multica 从第一天起就是围绕这一理念构建的。
和当年的 Multics 一样,这一判断建立在"多路复用"之上。一个小团队不该因为人数少就显得能力有限。有了合适的系统,两名工程师加上一组 agents就能发挥出二十人团队的推进速度。
## 功能特性
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。

View File

@@ -0,0 +1,33 @@
import { app } from "electron";
import { execSync } from "node:child_process";
/**
* Resolve the running app version. In packaged builds this is the value
* `electron-builder` baked into package.json via `extraMetadata.version`
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
* `app.getVersion()` matches the GitHub Release tag exactly.
*
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
* the Settings → Updates panel and any other UI surfacing the version
* would mislead developers into thinking they're running ancient builds.
* Fall back to `git describe --tags --always --dirty` (same source the
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
* unavailable for whatever reason, we just return the package.json value.
*/
export function getAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
}
try {
const raw = execSync("git describe --tags --always --dirty", {
cwd: app.getAppPath(),
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (!raw) return app.getVersion();
return raw.replace(/^v/, "");
} catch {
return app.getVersion();
}
}

View File

@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { getAppVersion } from "./app-version";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
@@ -110,6 +111,22 @@ function createWindow(): void {
return { action: "deny" };
});
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
}
});
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
@@ -203,7 +220,7 @@ if (!gotTheLock) {
ipcMain.on("app:get-info", (event) => {
const p = process.platform;
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
event.returnValue = { version: app.getVersion(), os };
event.returnValue = { version: getAppVersion(), os };
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen

View File

@@ -7,7 +7,7 @@ import { api } from "@multica/core/api";
import { useHasOnboarded } from "@multica/core/paths";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { PageviewTracker } from "./components/pageview-tracker";
@@ -110,21 +110,58 @@ function AppContent() {
: undefined;
useDaemonIPCBridge(activeWsId);
// Onboarding and zero-workspace both resolve to an overlay, but
// onboarding wins: a user who hasn't completed it gets the onboarding
// overlay regardless of how many workspaces already exist.
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
// judgment in callback / login:
// un-onboarded:
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// already onboarded:
// zero workspaces → /workspaces/new overlay
// ≥1 workspaces → no overlay, fall through to dashboard
//
// The "un-onboarded but in workspace" state is now physically impossible
// because backend transactions atomically set onboarded_at when a user
// joins the `member` table. Anyone with workspaces is by definition
// onboarded.
useEffect(() => {
if (!user || !workspaceListFetched) return;
if (!user || !workspaceListFetched) return undefined;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return;
if (overlay) return undefined;
if (wsCount > 0) return undefined;
if (!hasOnboarded) {
open({ type: "onboarding" });
return;
// Look up pending invitations by email. Network blip is non-fatal —
// fall through to onboarding so the user isn't stuck on a blank
// window. The sidebar's pending-invitations dropdown will surface
// missed invites later once they're onboarded.
let cancelled = false;
void api
.listMyInvitations()
.then((invites) => {
if (cancelled) return;
const { overlay: latestOverlay, open: latestOpen } =
useWindowOverlayStore.getState();
if (latestOverlay) return;
if (invites.length > 0) {
qc.setQueryData(workspaceKeys.myInvitations(), invites);
latestOpen({ type: "invitations" });
} else {
latestOpen({ type: "onboarding" });
}
})
.catch(() => {
if (cancelled) return;
const { overlay: latestOverlay, open: latestOpen } =
useWindowOverlayStore.getState();
if (latestOverlay) return;
latestOpen({ type: "onboarding" });
});
return () => {
cancelled = true;
};
}
if (wsCount === 0) {
open({ type: "new-workspace" });
}
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
open({ type: "new-workspace" });
return undefined;
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect

View File

@@ -65,5 +65,7 @@ function overlayPath(overlay: WindowOverlay): string {
return "/onboarding";
case "invite":
return `/invite/${overlay.invitationId}`;
case "invitations":
return "/invitations";
}
}

View File

@@ -5,12 +5,13 @@ import { Button } from "@multica/ui/components/ui/button";
type CheckState =
| { status: "idle" }
| { status: "checking" }
| { status: "up-to-date"; currentVersion: string }
| { status: "up-to-date" }
| { status: "available"; latestVersion: string }
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const [state, setState] = useState<CheckState>({ status: "idle" });
const currentVersion = window.desktopAPI.appInfo.version;
const handleCheck = useCallback(async () => {
setState({ status: "checking" });
@@ -22,7 +23,7 @@ export function UpdatesSettingsTab() {
setState(
result.available
? { status: "available", latestVersion: result.latestVersion }
: { status: "up-to-date", currentVersion: result.currentVersion },
: { status: "up-to-date" },
);
}, []);
@@ -35,6 +36,15 @@ export function UpdatesSettingsTab() {
</p>
<div className="mt-6 divide-y">
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Current version</p>
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
v{currentVersion}
</p>
</div>
</div>
<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>
@@ -45,7 +55,7 @@ export function UpdatesSettingsTab() {
{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}).
You&apos;re on the latest version.
</p>
)}
{state.status === "available" && (

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { InvitationsPage } from "@multica/views/invitations";
import { OnboardingFlow } from "@multica/views/onboarding";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
@@ -58,6 +59,7 @@ function WindowOverlayInner() {
onBack={onBack}
/>
)}
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws) => {

View File

@@ -61,6 +61,13 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
}
return true;
}
if (path === "/invitations") {
overlay.open({ type: "invitations" });
if (router && router.state.location.pathname !== "/") {
router.navigate("/", { replace: true });
}
return true;
}
if (path.startsWith("/invite/")) {
let id = "";
try {

View File

@@ -15,6 +15,7 @@ import { create } from "zustand";
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string }
| { type: "invitations" }
| { type: "onboarding" };
interface WindowOverlayStore {

View File

@@ -1 +1,38 @@
import "@testing-library/jest-dom/vitest";
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
key: (index: number) => Array.from(values.keys())[index] ?? null,
removeItem: (key: string) => {
values.delete(key);
},
setItem: (key: string, value: string) => {
values.set(key, value);
},
};
}
const localStorageIsUsable =
typeof globalThis.localStorage?.getItem === "function" &&
typeof globalThis.localStorage?.setItem === "function" &&
typeof globalThis.localStorage?.removeItem === "function" &&
typeof globalThis.localStorage?.clear === "function";
if (!localStorageIsUsable) {
const storage = createMemoryStorage();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(window, "localStorage", {
configurable: true,
value: storage,
});
}

View File

@@ -10,6 +10,7 @@
"members-roles",
"issues",
"comments",
"project-resources",
"---Agents---",
"agents",
"agents-create",

View File

@@ -0,0 +1,144 @@
---
title: Project Resources
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
---
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
## Mental model
A project is no longer just a label. It is a small **resource container**:
- A project has 0..N **resources**.
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
## Today: `github_repo`
The first resource type ships ready to use:
```json
{
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/owner/repo",
"default_branch_hint": "main"
}
}
```
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
## Attaching repos at project creation
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.
From the **CLI**:
```bash
# Create + attach in one shot. The server attaches resources in the same
# transaction as the project create — invalid resources roll back the whole
# operation, so you never end up with a project that has half its resources.
multica project create \
--title "Agent UX 2026" \
--repo https://github.com/multica-ai/multica
# Manage resources later
multica project resource list <project-id>
multica project resource add <project-id> --type github_repo --url <url>
multica project resource remove <project-id> <resource-id>
# Generic escape hatch for any resource_type the server understands —
# no CLI change needed when a new type ships:
multica project resource add <project-id> \
--type notion_page \
--ref '{"page_id":"…","title":"…"}'
```
`--repo` may be repeated; each value is attached as a separate `github_repo` resource.
## What the agent sees at runtime
When the daemon spawns an agent for an issue inside a project, two things happen:
### 1. `.multica/project/resources.json`
A structured pass-through of the API response, written into the agent's working directory:
```json
{
"project_id": "…",
"project_title": "Agent UX 2026",
"resources": [
{
"id": "…",
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/multica-ai/multica",
"default_branch_hint": "main"
}
}
]
}
```
Skills, helper scripts, or the agent itself can parse this file when they need the *exact* set of resources for the run.
### 2. A "Project Context" section in the meta-skill prompt
The agent's `CLAUDE.md` / `AGENTS.md` (depending on provider) now includes a human-readable summary:
```
## Project Context
This issue belongs to **Agent UX 2026**.
Project resources (also written to `.multica/project/resources.json`):
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
Resources are pointers — open them only when relevant to the task. For
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
```
The text is intentionally minimal. The full payload is on disk; the prompt only orients the agent so it knows the project exists and what's attached.
### Failure mode
Resource fetch is **best-effort**. If the API call fails, the project section is omitted from the prompt and the file is not written, but the task still starts. Agents never block on missing project context.
## Adding a new resource type
The whole point of the abstraction is that new types are cheap. The full path:
1. **Server validator** (`server/internal/handler/project_resource.go`) — add a case in `validateAndNormalizeResourceRef` that parses and normalizes the new payload.
2. **Daemon meta-skill formatter** (`server/internal/daemon/execenv/runtime_config.go`) — add a case in `formatProjectResource` so the agent prompt renders the new type as a readable bullet.
3. **TypeScript types** (`packages/core/types/project.ts`) — extend `ProjectResourceType` and add the payload interface.
4. **UI renderer** (`packages/views/projects/components/project-resources-section.tsx`) — add a case in `ResourceRow` for the new type.
There is **no schema migration**, no new sqlc query, no new endpoint, **and no CLI change** — the CLI's generic `--ref '<json>'` flag accepts any payload the validator understands, so day-one support for a new type is purely the four steps above. (You may *optionally* add a per-type CLI shortcut later; not required.)
The same `project_resource` table and the same three CRUD calls handle every type.
## Workspace repos vs. project repos
The repo list shown to the agent (`## Repositories` block in `CLAUDE.md` / `AGENTS.md`) is chosen by the daemon claim handler with this precedence:
- **Project has at least one `github_repo` resource** → only those repos are surfaced to the agent. Workspace-bound repos are intentionally hidden so the agent doesn't have to guess which one belongs to this issue.
- **Project has no `github_repo` resources (or the issue isn't in a project)** → fall back to the workspace's repo list as before.
This keeps the agent's working set tight: when a project is explicit about its repos, that's the authoritative answer. The structured resource list at `.multica/project/resources.json` always carries the full set, so a skill that wants to inspect everything still can.
The daemon mirrors this on the checkout side: when a task arrives with project-scoped `github_repo` URLs, those URLs are merged into the per-workspace allowlist *and* synced into the local repo cache before the agent spawns. So a project repo URL that isn't bound at the workspace level is still a valid argument to `multica repo checkout` — the daemon won't reject it as "not configured." The allowlist split is internal: workspace-bound URLs and task-scoped URLs are tracked separately, so a workspace-repos refresh doesn't accidentally revoke a project URL mid-run.
## What's intentionally **not** in scope here
- **Cross-project sharing.** Each resource lives on exactly one project today.
- **Per-skill resource scoping.** All resources are visible to every skill on the agent's run; type-aware filtering is a follow-up.
- **Caching / sync.** `github_repo` is just metadata — checkout still happens via `multica repo checkout` on demand. Cached document text for Notion / Google Docs will arrive with those types.
These are deliberate omissions — the goal of the first cut is to validate the abstraction with the smallest set of moving parts.

View File

@@ -0,0 +1,28 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { InvitationsPage } from "@multica/views/invitations";
export default function InvitationsRoutePage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Unauthenticated users have nowhere meaningful to land here — kick them
// through login and bring them back. The login page will eventually run
// its own listMyInvitations() check and route them here again.
useEffect(() => {
if (!isLoading && !user) {
router.replace(
`${paths.login()}?next=${encodeURIComponent(paths.invitations())}`,
);
}
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return <InvitationsPage />;
}

View File

@@ -2,7 +2,7 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { useConfigStore } from "@multica/core/config";
import { workspaceKeys } from "@multica/core/workspace/queries";
@@ -27,6 +27,32 @@ import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import Link from "next/link";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
/**
* Pick where a logged-in user with no explicit `?next=` should land.
* Un-onboarded users with pending invitations on their email get routed to
* the batch /invitations page; everyone else falls through to the standard
* resolver. A network blip on listMyInvitations is non-fatal — we fall
* through rather than trap the user on an error screen.
*/
async function resolveLoggedInDestination(
qc: QueryClient,
hasOnboarded: boolean,
workspaces: Workspace[],
): Promise<string> {
if (!hasOnboarded) {
try {
const invites = await api.listMyInvitations();
if (invites.length > 0) {
qc.setQueryData(workspaceKeys.myInvitations(), invites);
return paths.invitations();
}
} catch {
// fall through
}
}
return resolvePostAuthDestination(workspaces, hasOnboarded);
}
function LoginPageContent() {
const router = useRouter();
const qc = useQueryClient();
@@ -72,33 +98,28 @@ function LoginPageContent() {
});
return;
}
if (!hasOnboarded) {
router.replace(paths.onboarding());
return;
}
if (nextUrl) {
router.replace(nextUrl);
return;
}
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
router.replace(resolvePostAuthDestination(list, hasOnboarded));
void resolveLoggedInDestination(qc, hasOnboarded, list).then((dest) =>
router.replace(dest),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
const handleSuccess = () => {
const handleSuccess = async () => {
// Read the latest user snapshot directly — the closure's `hasOnboarded`
// was captured before login completed and would be stale here.
const currentUser = useAuthStore.getState().user;
const onboarded = currentUser?.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
return;
}
if (nextUrl) {
router.push(nextUrl);
return;
}
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
router.push(resolvePostAuthDestination(list, onboarded));
const dest = await resolveLoggedInDestination(qc, onboarded, list);
router.push(dest);
};
// Build Google OAuth state: encode platform + next URL so the callback

View File

@@ -32,7 +32,7 @@ export default function OnboardingPage() {
const hasOnboarded = useHasOnboarded();
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user && hasOnboarded,
enabled: !!user,
});
useEffect(() => {
@@ -40,7 +40,15 @@ export default function OnboardingPage() {
if (!isLoading && !user) router.replace(paths.login());
return;
}
if (hasOnboarded && workspacesFetched) {
if (!workspacesFetched) return;
// Bounce out only when onboarding genuinely doesn't apply: the user is
// already onboarded. We deliberately don't bounce on `workspaces.length`
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
// hasWorkspaces bounce here would kick the user out before Steps 45
// (runtime / agent / first issue) can run. The new entry-point
// judgment in callback / login handles "where should this user go on
// login" so OnboardingPage no longer needs to second-guess it.
if (hasOnboarded) {
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);

View File

@@ -2,13 +2,21 @@ 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(),
}));
const {
mockPush,
mockSearchParams,
mockLoginWithGoogle,
mockListWorkspaces,
mockListMyInvitations,
mockSetQueryData,
} = vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
mockListMyInvitations: vi.fn(),
mockSetQueryData: vi.fn(),
}));
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
id: "user-1",
@@ -28,7 +36,7 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ setQueryData: vi.fn() }),
useQueryClient: () => ({ setQueryData: mockSetQueryData }),
}));
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
@@ -46,12 +54,16 @@ vi.mock("@multica/core/auth", async () => {
});
vi.mock("@multica/core/workspace/queries", () => ({
workspaceKeys: { list: () => ["workspaces"] },
workspaceKeys: {
list: () => ["workspaces"],
myInvitations: () => ["invitations", "mine"],
},
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockListWorkspaces,
listMyInvitations: mockListMyInvitations,
googleLogin: vi.fn(),
},
}));
@@ -61,26 +73,78 @@ import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
// Snapshot keys before deleting — forEach + delete skips entries because
// the iteration index advances while the underlying list shrinks.
Array.from(mockSearchParams.keys()).forEach((k) =>
mockSearchParams.delete(k),
);
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockListWorkspaces.mockResolvedValue([]);
mockListMyInvitations.mockResolvedValue([]);
});
it("unonboarded user lands on /onboarding regardless of next=", async () => {
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
// nextUrl is a fast path — listMyInvitations should not be queried.
expect(mockListMyInvitations).not.toHaveBeenCalled();
});
it("unonboarded user with no next= also lands on /onboarding", async () => {
it("unonboarded user with no next= and no pending invitations lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
expect(mockListMyInvitations).toHaveBeenCalled();
});
it("unonboarded user with pending invitations lands on /invitations", async () => {
mockListMyInvitations.mockResolvedValue([
{
id: "inv-1",
workspace_id: "ws-1",
workspace_name: "Acme",
role: "member",
status: "pending",
},
]);
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.invitations());
});
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
});
it("onboarded user with workspace lands in that workspace", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
);
mockListWorkspaces.mockResolvedValue([
{
id: "ws-1",
name: "Acme",
slug: "acme",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "ACME",
created_at: "",
updated_at: "",
},
]);
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
});
// Already-onboarded users skip the listMyInvitations check; new invites
// surface in the sidebar instead of the wall.
expect(mockListMyInvitations).not.toHaveBeenCalled();
});
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
@@ -109,4 +173,12 @@ describe("CallbackPage", () => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
});
it("falls through to /onboarding when listMyInvitations errors", async () => {
mockListMyInvitations.mockRejectedValue(new Error("network"));
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
});
});

View File

@@ -66,13 +66,42 @@ function CallbackContent() {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
const onboarded = loggedInUser.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
// 1. nextUrl wins: a `next=/invite/<id>` always survives the OAuth
// round-trip — the user clicked a specific link and we should
// honor exactly that destination.
if (nextUrl) {
router.push(nextUrl);
return;
}
router.push(
nextUrl || resolvePostAuthDestination(wsList, onboarded),
);
// 2. Un-onboarded users may have pending invitations on their
// email even when no `next=` was carried (came from a fresh
// login on app.multica.ai instead of clicking the email link,
// or `state` was lost across the round-trip). Look them up by
// email and route to the batch /invitations page if any.
// Already-onboarded users skip this lookup — their new invites
// surface in the sidebar dropdown, not as a forced wall.
if (!onboarded) {
try {
const invites = await api.listMyInvitations();
if (invites.length > 0) {
qc.setQueryData(workspaceKeys.myInvitations(), invites);
router.push(paths.invitations());
return;
}
} catch {
// Network blip on the invite lookup is non-fatal — fall through
// to the normal post-auth destination so the user isn't stuck
// on a blank callback screen. Worst case they land on
// /onboarding and the sidebar will surface invites later.
}
}
// 3. Default: hand off to the resolver (onboarding for first-timers,
// first workspace for returning users, /workspaces/new for
// onboarded users with zero workspaces).
router.push(resolvePostAuthDestination(wsList, onboarded));
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

@@ -0,0 +1,61 @@
import { Instrument_Serif } from "next/font/google";
// Editorial-style 404. Cream + ink + terracotta palette is intentionally
// inline — these brand experiments have not been promoted to design tokens.
// The route lives outside the (landing) group's font scope, so we attach
// Instrument Serif locally to match the editorial direction.
const CREAM = "#faf9f6";
const INK = "#1b1812";
const TERRACOTTA = "#a64a2c";
const editorialSerif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
variable: "--font-serif",
});
export default function NotFound() {
return (
<section
className={`${editorialSerif.variable} relative flex min-h-screen flex-col items-center justify-center px-6 py-16`}
style={{ backgroundColor: CREAM, color: INK }}
>
{/* tracking is wider than Tailwind's tracking-widest (0.1em) — editorial eyebrow detail, deliberate. */}
<div
className="flex items-center gap-3 text-xs uppercase tracking-[0.25em]"
style={{ color: TERRACOTTA }}
>
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
<span>error · not found</span>
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
</div>
{/* Fluid hero size + ultra-tight leading; outside the Tailwind type scale by design. */}
<h1 className="mt-12 font-serif text-[clamp(7rem,16vw,15rem)] leading-[0.85] tracking-tight">
404
</h1>
<p className="mt-10 max-w-xl text-center font-serif text-3xl leading-tight">
This page{" "}
<em className="not-italic" style={{ color: TERRACOTTA }}>
doesn&rsquo;t exist
</em>
.
</p>
<p
className="mt-5 max-w-md text-center text-sm leading-relaxed"
style={{ color: INK, opacity: 0.6 }}
>
The URL may have changed, the resource may be deleted, or you arrived from a stale link.
</p>
<a
href="/"
className="mt-12 inline-flex h-10 items-center rounded-full px-6 text-sm font-medium transition hover:opacity-90"
style={{ background: INK, color: CREAM }}
>
Back to Multica
</a>
</section>
);
}

View File

@@ -283,6 +283,52 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.21",
date: "2026-04-30",
title: "Quick Capture Overhaul, Mermaid Diagrams & Typed Project Resources",
changes: [],
features: [
"Quick Capture replaces the old New Issue dialog — continuous-create mode, file uploads, and automatic enrichment from pasted URLs",
"Mermaid diagrams render inline in markdown, with a fullscreen lightbox for complex graphs",
"Projects can bind their own repo, separate from the workspace default",
"Permission-aware UI across agents, comments, runtimes, and skills — actions you can't take are no longer offered",
],
improvements: [
"Daemon `/tasks/claim` polling uses a Redis empty-claim fast-path, dropping idle DB load and reclaiming disk on long-open issues",
"Multica Agent commits include a `Co-authored-by` trailer for proper Git attribution",
"Desktop blocks Cmd+R / Ctrl+R / F5 from reloading the app and shows the real version in dev and Updates settings",
],
fixes: [
"Quick Create no longer invents requirements beyond user input, and subscribes the requester to the issue it creates",
"Inbox jumps straight to the targeted comment, and auto-archives when the issue is marked Done from the detail page",
"Task rerun starts a fresh session and skips poisoned resume state",
"Invitees land on their workspace after sign-in instead of being forced through `/onboarding`",
],
},
{
version: "0.2.20",
date: "2026-04-29",
title: "Create Issue by Agent, Agent Presence v3 & Daemon WebSocket Heartbeat",
changes: [],
features: [
"Create Issue by Agent — press `c`, write one line, pick an agent; issue creation runs async and the result lands in your inbox",
"Agent Presence v3 — availability and last-task split into clearer signals, with an execution log on the issue panel showing active and recent runs",
"Daemon ↔ server heartbeat now flows over WebSocket with HTTP fallback, cutting task wakeup latency",
"Mention picker ranks suggestions by your local recency",
],
improvements: [
"Server caches PAT / daemon token lookups in Redis, so large fleets stop hammering the database on every request",
"Backend default agent CLI args via `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` env vars",
"Manual and agent create-issue flows share one dialog shell, and picker agents become the default assignee",
],
fixes: [
"Create-issue-by-agent no longer leaves tasks stuck queued, and no longer duplicates the issue when an attachment upload fails",
"Agent comments respect newlines instead of rendering literal `\\n`, and multi-line replies keep their formatting",
"Agent-authored root comments no longer inherit parent @mentions, breaking accidental agent loops",
"Cursor agent on Windows preserves multi-line prompts",
],
},
{
version: "0.2.19",
date: "2026-04-28",

View File

@@ -283,6 +283,52 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.2.21",
date: "2026-04-30",
title: "Quick Capture 全面升级、Mermaid 图表与 Typed Project Resources",
changes: [],
features: [
"Quick Capture 取代旧的 New Issue 弹窗 —— 支持连续创建、文件上传,并能根据粘贴的 URL 自动丰富标题与描述",
"Markdown 内联渲染 Mermaid 图表,复杂图支持全屏 lightbox",
"Project 支持单独绑定 repo无需依赖 workspace 默认配置",
"Agent / 评论 / Runtime / Skill 全面接入权限感知 UI没有权限的操作不再展示",
],
improvements: [
"Daemon `/tasks/claim` 轮询走 Redis 空认领 fast-path空闲态 DB 压力下降,长期 open 的 Issue 自动回收磁盘",
"Multica Agent 的 Git 提交自动追加 `Co-authored-by` trailer归属更清晰",
"Desktop 拦截 Cmd+R / Ctrl+R / F5 防止意外刷新,开发模式与 Updates 设置中均展示真实版本号",
],
fixes: [
"Quick Create 不再凭空脑补需求,并自动把发起人订阅到 Issue",
"Inbox 点击通知后立即跳到目标评论;从 Issue 详情页 Mark as Done 时自动归档",
"Task rerun 启动全新 session跳过被污染的 resume 状态",
"受邀成员登录后路由到所在 workspace不再强制带去 `/onboarding`",
],
},
{
version: "0.2.20",
date: "2026-04-29",
title: "Create Issue by Agent、Agent Presence v3 与 Daemon WebSocket 心跳",
changes: [],
features: [
"Create Issue by Agent —— 按 `c` 输入一句话并选 AgentIssue 异步创建,结果回执送达 Inbox",
"Agent Presence v3 —— 可用性与最近任务拆成两条更清晰的信号Issue 详情右侧新增 Execution Log可看到当前 active run 与历史 run",
"Daemon ↔ Server 心跳改走 WebSocketHTTP 自动 fallback任务起跑延迟更低",
"Mention 选择器按本机最近使用排序",
],
improvements: [
"Server 用 Redis 缓存 PAT / Daemon Token 校验,大型团队不再让 DB 抗下每次请求",
"后端支持通过 `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` 配置 Agent CLI 默认参数",
"Manual 与 Agent 创建 Issue 共享同一个 Dialog 外壳picker Agent 会被默认设为 assignee",
],
fixes: [
"Create Issue by Agent 不再卡住 queued 任务,也不再因附件上传失败而重复创建 Issue",
"Agent 评论保留换行,不再渲染成字面量 `\\n`,多行回复的格式也被完整保留",
"Agent 自身发出的根评论不再继承父评论的 @mention避免互相唤起的死循环",
"Windows 下 Cursor Agent 启动时保留多行 prompt",
],
},
{
version: "0.2.19",
date: "2026-04-28",

View File

@@ -1,802 +0,0 @@
# Agent / Runtime 状态系统重设计
> **文档定位**:这是一份完整的设计 + 实施方案。任何一个新加入的工程师 / 设计师 / 产品,看完这份文档应该能:
> - 理解我们要解决的问题、为什么这么解决
> - 知道每个阶段做什么、按什么顺序做、产出什么
> - 在不读代码的前提下能独立讨论方案
>
> 本文档是 [agent-status-design-brief.md](./agent-status-design-brief.md) 和 [agent-status-redesign-plan.md](./agent-status-redesign-plan.md) 的合并升级版,达成共识后会取代它们。
---
## 目录
1. [背景与目标](#一背景与目标)
2. [核心思想](#二核心思想)
3. [状态系统规范](#三状态系统规范)
4. [数据架构](#四数据架构)
5. [跨平台策略](#五跨平台策略)
6. [设计语言](#六设计语言)
7. [实施分阶段](#七实施分阶段)
8. [验收标准](#八验收标准)
9. [边界与不做的事](#九边界与不做的事)
10. [风险与注意事项](#十风险与注意事项)
11. [参考](#十一参考)
---
## 一、背景与目标
### 1.1 Multica 的产品定位提醒
Multica 是 AI-native 的任务管理平台——agent 是和人对等的"同事"。一个工作区里同时有人和 agent 在协作,相互分配任务、评论、订阅。
理解这个定位很重要,因为它直接决定了状态系统的需求:**用户对 agent 的预期跟"对同事的预期"是相同的**——我希望随时知道这个同事现在能不能接活、在不在线、是不是出问题了。
### 1.2 当前的核心问题
**所有界面都在直接展示后端的原始字段,缺少"用户视角"的状态翻译层。**
具体表现(按用户感知严重度排序):
1. **Agent 列表的 "Idle" 绿点会骗人**——daemon 已经死了agent 仍然显示 Idle。用户分配任务后没有任何反馈长时间困惑"为什么 agent 不动"。
2. **Runtime 列表只有一个圆点**——"刚断线 5 分钟"和"3 个月前断线"视觉一模一样,用户判断不出严重程度。
3. **Issue 详情页多个 agent 同时工作时只有 1 个可见**——其他 agent 的卡片埋在下方滚动区,没有总览。
4. **任务失败时只有红色 X**——`agent_error`agent 自己挂了)和 `runtime_offline`daemon 离线)处理方式完全不同,但视觉上不区分。
5. **Chat 发消息后只有一个 spinner 转数分钟**——无法区分"排队"、"思考"、"调用工具"、"生成回答"四个阶段。
### 1.3 根本原因
后端字段是**任务调度的内部状态**`agent.status` = idle/working/blocked/error不是给用户看的。当前前端把后端字段直接渲染给用户就把"调度内部视角"暴露成了"用户视角"。
但用户不关心 agent 的内部调度状态,用户关心的是:
- **能不能用?**(在线 / 离线)
- **如果在用,在干什么?**(工作中 / 排队中 / 失败了)
- **如果不能用,为什么?**daemon 离线 / CLI 没装 / 任务超时)
这些问题的答案,**没有任何一个能从单一字段直接得到**——它们都需要把多个数据源聚合后才能算出来。
### 1.4 目标
**做一套"用户视角"的状态系统**,覆盖三类对象:
- **Agent**5 态available / working / pending / failed / offline跨界面一致
- **Runtime**4 态online / recently_lost / offline / about_to_gc
- **Task**阶段化queued / dispatched / thinking / using_tool / generating / completed / failed
完成这套系统后,下列现有界面会自然变好:
- Agents 列表 / 详情:看到真实可用性
- Runtimes 列表 / 详情:看到机器健康度
- Issue Detail多 agent 全景 + 失败原因显示
- 跨界面 hover cardissue assignee / autopilot / chat / @mention):状态一致
- Chat分阶段进度
---
## 二、核心思想
### 2.1 一句话
**把"用户视角的状态"做成前端的派生量**——后端只暴露真相(任务存在、心跳到达),前端按 UI 需要把这些真相聚合成"用户能理解的状态"。
### 2.2 三个设计原则
#### 原则 1派生函数住在前端不污染后端
`agent.status` / `runtime.status` 这些后端字段是 **物理事实**
- "task X 现在 running"
- "daemon Y 45 秒前发了心跳"
而 "Available / Working / Pending / Failed / Offline" 这些是 **UI 翻译**
- "FAILED 状态保持 2 分钟" 是设计决策
- "蓝色表示 working" 是视觉决策
- 不同界面可能要不同视角issue 里看"任务阶段"、列表里看"是否可分配"
**UI 翻译应该住在前端,跟着设计需求一起迭代。** 把它放进后端,每改一次都要 migration + WS payload 兼容 + 老客户端处理,迭代周期从分钟变周。
#### 原则 2服务器状态住在 TanStack Query cache不复制进 Zustand
我们的全局状态有两套:
- **Zustand** 管 client stateUI 选中、筛选器、modal 开关)
- **TanStack Query cache** 管 server state来自 API 的所有数据)
TQ cache 不是组件级缓存,**它本身就是 server state 的全局状态管理**。跨组件共享、按 key 索引、自动去重。
派生状态是 server data 的纯函数。结果不存——每次组件渲染时按需用 `useMemo` 算一遍。算的成本是几个 filter + some 调用,可忽略。
#### 原则 3聚合在前端做但要避免 N+1
派生状态需要 3 份原始数据:
- agents 列表
- runtimes 列表
- 当前活跃任务列表active tasks
朴素做法:每个组件 `useAgentTasks(agentId)`——一个 issues 列表 30 个 agent 头像 = 30 次请求。这就是 N+1。
正解:**进工作区时一次性拉"全工作区的活跃任务"**(数据量天然不大,活跃任务永远是少数),存进 TQ cache。所有组件共享这一份缓存按 agentId 在内存里 filter——零额外请求。
这是把"per-agent 的数据需求"转换成"全工作区的集合数据需求"。集合数据天然只需要 1 次请求。
---
## 三、状态系统规范
### 3.1 Agent 五态
| 状态 | 颜色 | 用户语义 | 出现条件 |
|---|---|---|---|
| **Available** | 🟢 绿 | 在线空闲,可以接活 | runtime 在线 + 没有活跃任务 |
| **Working** | 🔵 蓝 | 正在干活 | runtime 在线 + 至少一个任务在执行 |
| **Pending** | 🟡 黄 | 任务排着但没在跑 | runtime 在线 + 0 个执行中 + ≥1 个排队 |
| **Failed** | 🔴 红 | 最近一次失败 | 最近 2 分钟内有任务失败 |
| **Offline** | ⚫ 灰 | Daemon 离线,不可用 | runtime 离线(包含 CLI 未安装) |
**复合维度**:当 agent 是 Working 但同时有任务排队,主状态保持 Working旁边带 `+N` 角标("还有 N 个排队")。
**派生规则**(按优先级匹配,命中即返回):
```ts
type AgentPresence = "available" | "working" | "pending" | "failed" | "offline"
function deriveAgentPresence(input: {
agent: Agent
runtime: AgentRuntime
recentTasks: AgentTask[] // 该 agent 最近 N 个任务
now: number // 当前时间戳
}): AgentPresence {
// 1. Runtime 离线(含 CLI 未安装)→ offline
if (input.runtime.status === "offline") return "offline"
// 2. 最近窗口内有 failed task → failed
const recentFailed = input.recentTasks.find(
t => t.status === "failed" &&
(input.now - new Date(t.completed_at).getTime()) < FAILED_WINDOW_MS
)
if (recentFailed) return "failed"
// 3. 有 running task → working
if (input.recentTasks.some(t => t.status === "running")) return "working"
// 4. 有 queued/dispatched task → pending
if (input.recentTasks.some(t => t.status === "queued" || t.status === "dispatched")) {
return "pending"
}
// 5. Otherwise → available
return "available"
}
```
**复合维度的派生**
```ts
function deriveAgentPresenceDetail(...): {
presence: AgentPresence
runningCount: number
queuedCount: number
failureReason?: TaskFailureReason // 仅 presence === "failed" 时有值
}
```
**待确认常量**
- `FAILED_WINDOW_MS = 2 * 60 * 1000`2 分钟)。
- 短到避免污染(任务失败的红点不会一直黏着)
- 长到让用户能看见(不会还没看就消失)
- **未来可能扩展**2 分钟内强提示(红色动效),之后降级为 tooltip 的"最近失败"摘要——这样既不刺眼,又不丢信息。本期先用固定 2 分钟。
### 3.2 Runtime 四态
| 状态 | 触发条件 | 用户语义 |
|---|---|---|
| **Online** | 最近 45 秒内有心跳 | 健康,能接任务 |
| **Recently Lost** | 离线但 < 5 分钟 | 可能短暂网络抖动 |
| **Offline** | 离线 5 分钟 ~ 7 天 | 长期离线,需要排查 |
| **About to GC** | 离线接近 7 天阈值 | 系统将自动清理 |
```ts
function deriveRuntimeHealth(runtime: AgentRuntime, now: number): RuntimeHealth {
if (runtime.status === "online") return "online"
const lastSeen = runtime.last_seen_at
? new Date(runtime.last_seen_at).getTime()
: 0
const offlineFor = now - lastSeen
if (offlineFor < 5 * 60 * 1000) return "recently_lost"
if (offlineFor > 6 * 24 * 3600 * 1000) return "about_to_gc" // 7d - 1d
return "offline"
}
```
> **关于 CLI 未安装等"runtime 在线但跑不了"的场景**:归并到 `offline`tooltip 写明具体原因("CLI 未安装"、"Daemon 启动中")。不为此引入第六态——状态空间膨胀代价高,分辨率应该用 tooltip / detail 而不是顶层枚举。
### 3.3 Task 阶段化
用于 Issue Detail 和 Chat 显示当前任务在哪一步:
```ts
type TaskStage =
| "queued"
| "dispatched" // dispatched 但进程没起来
| "thinking" // running最后一条 message 是 thinking
| "using_tool" // running最后一条是 tool_use / tool_result
| "generating" // running最后一条是 text
| "completed"
| "failed"
| "cancelled"
```
派生函数读 task 状态 + 最后一条 message 的 type 即可决定阶段。
### 3.4 失败原因映射
`task.failure_reason` 来自后端 migration 0555 个值映射到中文标签:
```ts
const FAILURE_REASON_LABEL: Record<TaskFailureReason, string> = {
agent_error: "Agent 执行报错",
timeout: "执行超时",
runtime_offline: "Daemon 离线",
runtime_recovery: "Daemon 重启回收",
manual: "用户取消",
}
```
每种原因要给用户对应的处理建议(具体文案待设计师定)。
> ⚠️ **当前差异**:后端 schema 已有 `failure_reason`migration 055但前端 `packages/core/types/agent.ts` 的 `AgentTask` 接口**未暴露此字段**。阶段 0 必须同步:
> - 前端类型补 `failure_reason: TaskFailureReason | null`
> - 检查后端 `ListAgentTasks` / 新增的 `ListActiveTasksByWorkspace` 是否 SELECT 了这个字段
---
## 四、数据架构
### 4.1 数据流
```
后端真相
┌────────────┬─────────────┬──────────────┐
│ agents │ runtimes │ active_tasks│
│ (HTTP) │ (HTTP) │ (HTTP) │
└─────┬──────┴──────┬──────┴───────┬──────┘
│ │ │
└─────────────┴──────────────┘
TanStack Query cache
(全局共享)
派生函数(纯函数)
┌───────────────┴───────────────┐
▼ ▼
AgentPresence RuntimeHealth
│ │
▼ ▼
组件渲染5 态视觉) 组件渲染4 态视觉)
──────────────────────────────────────────────
实时更新
后端 WS 事件 ──→ 前端 invalidate query ──→ 重拉 ──→ 派生重算 ──→ UI 更新
桌面端额外:本机 IPC ──→ setQueryData 直接预填 ──→ 亚秒级响应
```
### 4.2 三个 query
```ts
// 进工作区时一次性拉
useQuery(['ws', wsId, 'agents']) // listAgents
useQuery(['ws', wsId, 'runtimes']) // listRuntimes
useQuery(['ws', wsId, 'active-tasks']) // ★ 新增getActiveTasksForWorkspace
```
**`active-tasks` 是新增 query**——返回当前工作区所有 status ∈ {queued, dispatched, running} 的任务。这份数据天然小(活跃任务不会很多),值得做成"全工作区一次拉"。
> ⚠️ **当前差异**:后端**没有此 endpoint**。现有只有 `listAgentTasks(agentId)`per-agent和 `getActiveTasksForIssue(issueId)`per-issue。阶段 0 必须新增 `GET /api/workspaces/:slug/active-tasks`——它仍然返回原始 task 列表,不做任何派生,**不违反"零后端聚合"原则**。
#### Runtime → Agents 是 reverse query
后端没有 "runtime 服务的 agent 列表" API。Agent 持有 `runtime_id` 外键,但 Runtime 没有反向关联。
**前端处理**:从已缓存的 `agents` 列表 `filter(a => a.runtime_id === rtId)` 即可。这不算 N+1agents 列表本来就要拉),是免费的 join。
### 4.3 WS 事件接线表
| WS 事件 | 触发的 invalidate |
|---|---|
| `agent:created` / `agent:archived` / `agent:updated` | `['ws', wsId, 'agents']` |
| `agent:status` | `['ws', wsId, 'agents']`(即使我们不用这个值,缓存 fresh 仍要) |
| `daemon:register` | `['ws', wsId, 'runtimes']` |
| `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` | `['ws', wsId, 'active-tasks']` |
| `task:progress` | (考虑节流,避免高频任务刷新缓存) |
| `daemon:heartbeat` | **故意忽略** —— 后端发送但前端不订阅,避免每 15 秒过度 refetch。后果runtime online → recently_lost 切换最坏延迟 75 秒45s sweeper + 30s 间隔)。设计上接受这个延迟。 |
**关键不变量**:每个派生函数依赖的字段都必须有对应 WS 事件覆盖。任何字段没事件覆盖,状态会卡住。每次新增派生维度都要回头检查这张表。
### 4.4 桌面端 IPC 桥接
桌面端通过 Electron IPC 直接读本机 daemon。这份数据
- 比 server WS **快 75 秒**IPC 亚秒server sweep 最坏 75s
- 包含 server 看不到的 `starting` / `stopping` / `cli_not_found` 等中间态
**不修改派生函数签名**——桌面端把 IPC 数据用 `setQueryData` 直接写进 runtime cache
```ts
// 仅桌面端apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts
window.electron.onDaemonStatus((status) => {
queryClient.setQueryData(
['ws', wsId, 'runtimes'],
(old) => old?.map(rt =>
rt.id === status.runtimeId ? mergeDaemonStatus(rt, status) : rt
)
)
})
```
派生函数完全不知道数据从哪来——它只读 cache。**桌面端自动获得亚秒级体验,零派生函数改动**。
---
## 五、跨平台策略
### 5.1 状态系统是平台无关的
派生函数、5 态/4 态规范、UI 视觉——**两端共享同一套**。设计稿只画一份。
### 5.2 数据源平台相关
| 平台 | Runtime 数据来源 | 状态变化感知延迟 |
|---|---|---|
| Web | server WS / HTTP | 最坏 75 秒 |
| Desktop本机 daemon | IPC + server | < 1 秒 |
| Desktop别人的 daemon | server WS / HTTP | 跟 Web 一样 |
数据源不同不影响 UI——派生函数读 cachecache 是"哪边更新就更新"。
### 5.3 操作能力平台相关
| 操作 | Web | Desktop自己机器 | Desktop别人机器 |
|---|---|---|---|
| 看状态 | ✅ | ✅ | ✅ |
| 重启 daemon | ❌ | ✅ | ❌ |
| 看 daemon logs | ❌ | ✅ | ❌ |
| 看 CLI 安装详情 | ❌ | ✅ | ❌ |
实现方式:组件按 `isLocalDaemon && isOwner` 条件渲染按钮。**不需要写进派生函数 / 状态系统**,是 UI 局部决策。
### 5.4 Daemon 卡片视觉对齐
桌面端 settings 里的"本机 daemon 卡片"必须跟云端 runtime 列表项视觉一致。同一台机器同一个概念,不能两套设计。
---
## 六、设计语言
### 6.1 直接复用 Skills 界面PR #1607、#1614、#1618、#1610
Skills 界面已经在 2026-04 完成重设计是当前产品的视觉锚点。Agents / Runtimes 直接照搬其规则:
1. **统一页头** `PageHeader`h-12 + mobile sidebar trigger
2. **响应式网格列表**`grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.2fr)_minmax(0,6rem)_auto]`
3. **每行三层信息**:主标题 → 描述line-clamp-1 muted→ 元数据xs muted
4. **关联对象用头像堆栈**:最多 3 + `+N`size=22 + `ring-2 ring-background` + `-space-x-1.5`
5. **卡片化列表 + 卡片内工具栏**:搜索和 scope tab 在 `CardToolbar`h-12不在页面级
6. **创建用多步 dialog**chooser → 表单可回退宽度按方法切换300ms 过渡
7. **空状态分文案**:图标 + 标题 + 三行说明 + 清晰 CTA
8. **长列表 `useScrollFade`**:上下边缘淡出
9. **头像统一 `ActorAvatar`**:传 `size`,自动支持 agent / 人
10. **权限检查 hook 化**`useCanEdit...`UI 提前隐藏/禁用
### 6.2 状态视觉的三个层级
每种派生状态在三个层级要保持一致:
- **Dot**(圆点):列表项、头像旁,最紧凑
- **Badge**(徽章):详情页头部、卡片角落,带图标 + 文字
- **Tooltip / Hover Card**:鼠标悬停展开完整信息
**跨界面一致性**:同一个 agent 无论出现在哪agents 列表 / issue assignee picker / autopilot 编辑 / chat 选择面板 / 评论 @),状态视觉必须完全一致。
---
## 七、实施分阶段
### 阶段 0 — 数据层地基(无 UI 改动)
**目标**:派生函数 + cache + WS + IPC 桥接全部就位。UI 暂不动。完成后所有 UI 阶段都能放心假设"派生状态可用、零额外请求"。
#### 后端工作Go
| 文件 | 改动 |
|---|---|
| `server/pkg/db/queries/agent_task.sql` | 新增 sqlc query`ListActiveTasksByWorkspace`SELECT 所有字段含 `failure_reason`,过滤 status ∈ {queued, dispatched, running} |
| `server/pkg/db/agent_task.sql.go` | `make sqlc` 自动生成 |
| `server/internal/handler/agent.go`(或新建 task handler | `ListActiveTasksByWorkspace` handler权限校验用户必须是 workspace member |
| `server/cmd/server/router.go` | 注册路由 `GET /api/workspaces/{slug}/active-tasks` |
| **核查**:现有 `ListAgentTasks` query | 确认 SELECT `failure_reason` 字段;如未 SELECT补上 |
#### 前端类型补全
| 文件 | 改动 |
|---|---|
| `packages/core/types/agent.ts` | `AgentTask` 接口加 `failure_reason: TaskFailureReason \| null`;新增 `TaskFailureReason = "agent_error" \| "timeout" \| "runtime_offline" \| "runtime_recovery" \| "manual"` |
#### 前端 API client
| 文件 | 改动 |
|---|---|
| `packages/core/api/client.ts` | 新增方法 `getActiveTasksForWorkspace(wsSlug): Promise<AgentTask[]>` |
#### 前端派生函数 + 类型
| 文件 | 内容 |
|---|---|
| `packages/core/agents/types.ts`(如不存在则新建) | `AgentPresence` / `AgentPresenceDetail` / `RuntimeHealth` 等类型 |
| `packages/core/agents/derive-presence.ts` | `deriveAgentPresence` / `deriveAgentPresenceDetail` 纯函数 |
| `packages/core/agents/derive-presence.test.ts` | 5 态全分支 + 边界 caseruntime null / tasks 空 / 时钟边界) |
| `packages/core/runtimes/derive-health.ts` | `deriveRuntimeHealth` |
| `packages/core/runtimes/derive-health.test.ts` | 4 态全分支 |
#### 前端 query + hook
| 文件 | 内容 |
|---|---|
| `packages/core/agents/active-tasks-query.ts` | `activeTasksOptions(wsId)` query options |
| `packages/core/agents/use-agent-presence.ts` | `useAgentPresence(agentId)` hook读 3 份 cache → 派生 |
| `packages/core/runtimes/use-runtime-health.ts` | `useRuntimeHealth(runtimeId)` hook |
| `packages/core/runtimes/use-runtime-agents.ts` | `useRuntimeAgents(runtimeId)` hook从 agents cache filter 出绑定的 agents |
#### 前端 WS 接线
| 文件 | 改动 |
|---|---|
| `packages/core/realtime/agent-runtime-sync.ts` | 新增专用 sync。订阅 `agent:*` / `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` / `daemon:register` → invalidate 对应 query。**显式不订阅 `daemon:heartbeat`**(接受 75 秒延迟) |
| `packages/core/realtime/use-realtime-sync.ts`(如已有全局 hook | 集成新 sync |
#### 桌面端 IPC 桥接
| 文件 | 内容 |
|---|---|
| `apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts` | 监听 `window.daemonAPI.onStatus(...)`,用 `queryClient.setQueryData` 把本机 daemon status merge 进对应 runtime cache |
#### 完成标准
- [ ] 后端 `GET /api/workspaces/:slug/active-tasks` 通 curl 测试,返回 active tasks 列表
- [ ] `deriveAgentPresence` / `deriveRuntimeHealth` 单测全部通过
- [ ] 控制台调用 `useActiveTasks(wsId)` 能拿到全工作区活跃任务
- [ ] 控制台调用 `useAgentPresence(agentId)` 能拿到正确的 5 态状态
- [ ] WS 接线表里所有事件都能正确 invalidate手测覆盖
- [ ] 关本机 daemon 后桌面端 runtime cache **1 秒内**变 offline
- [ ] 不动任何 UI 文件——这阶段 zero UI delta
### 阶段 1 — Agents + Runtimes 列表页
**目标**:两个列表用上派生状态,互相能看到对方。
#### 设计师产出(先于代码)
- 5 态 dot / badge / tooltip 三层视觉规范
- Working + 排队角标的复合视觉
- Failed 状态的 2 分钟时间窗口动效
- Runtime 4 态的视觉差异(不能再是同一个浅灰圆点)
#### 改造文件
| 文件 | 改动 |
|---|---|
| `packages/views/agents/components/agent-list-item.tsx` | 替换 `statusConfig[agent.status]``useAgentPresence(agentId)` |
| `packages/views/agents/components/agents-page.tsx` | 接 WS 订阅 |
| `packages/views/agents/config.ts` | 删除 `statusConfig`,新建 `presenceConfig` |
| `packages/views/runtimes/components/runtime-list.tsx` | 用派生 4 态;展示 last_seen、关联 agent 数、当前任务数 |
| `packages/views/runtimes/components/runtimes-page.tsx` | 接 WS 订阅 |
| `apps/desktop/.../local-daemon-card.tsx` | 视觉对齐云端 runtime 卡片 |
### 阶段 2 — Agents + Runtimes 详情页
**目标**详情页头部、profile card 状态联动Runtime token usage 信息架构整理。
#### 改造文件
| 文件 | 改动 |
|---|---|
| `packages/views/agents/components/agent-detail.tsx` | 头部 status badge 用派生 |
| `packages/views/agents/components/agent-profile-card.tsx` | 状态行和 runtime 行联动;展示当前任务数 + 最近失败原因 |
| `packages/views/runtimes/components/runtime-detail.tsx` | Token usage 主次重排核心指标置顶5 个图表折叠 / 下沉 |
| `packages/views/runtimes/components/usage-section.tsx` | API 调用按时间窗口拉(不再总是 90 天) |
### 阶段 3 — Issue Detail 任务展示
**目标**:多 agent 全景视图;任务阶段化;失败原因显式。
#### 新增文件
| 文件 | 内容 |
|---|---|
| `packages/core/agents/derive-task-stage.ts` | `deriveTaskStage` |
| `packages/core/agents/derive-task-stage.test.ts` | 单测 |
| `packages/views/issues/components/agent-task-row.tsx` | 单 agent 单任务一行 |
#### 改造文件
| 文件 | 改动 |
|---|---|
| `packages/views/issues/components/agent-live-card.tsx` | 从"sticky 一个 + 折叠列表"改为"每个 agent 一行" |
| `packages/views/issues/components/agent-transcript-dialog.tsx` | 失败时展示 failure_reason |
### 阶段 4 — 跨界面 Hover Card
**目标**:所有 agent 头像出现的位置都用统一的 hover card。
#### Hover Card 必须显示的内容(按重要度排序)
1. 派生 5 态状态
2. Runtime 健康(在线性 + last_seen 相对时间)
3. 当前任务N running / M queued
4. 最近失败(如果有):原因 + 时间
5. Agent 名称 + description
6. 关联 skills前 3 个 + `+N`
7. Owner
#### 必须接入的位置
| 位置 | 当前状态 |
|---|---|
| Agents 列表 / 详情 | ✅ 已有 |
| Issue Assignee Picker | ❌ 仅头像无状态 |
| Issue Detail 头部 assignee | ❌ 仅头像无状态 |
| Issue 列表 / 看板的分配头像 | ❌ 仅头像 |
| Autopilot 列表 / 编辑assignee | ❌ 仅头像 |
| Project lead picker | ❌ 仅头像 |
| Chat 选择 agent 面板 | ❌ 待确认 |
| 评论里的 @agent | ❌ 仅头像 |
#### 实施
`ActorAvatar` 组件挂载 hover card——一处改动上面所有位置自动获得统一卡片。
**N+1 风险已经被阶段 0 的"全工作区 active-tasks"消除**——hover card 只读 cache零额外请求。
### 阶段 5 — Chat 状态分阶段(独立 PR
工作量较大,跟流式渲染相关,单独排期。
#### 改造文件
| 文件 | 改动 |
|---|---|
| `packages/views/chat/components/chat-message-list.tsx` | `AssistantMessage``deriveTaskStage` 替代单 spinner |
| `packages/views/chat/components/chat-page.tsx` | WS 断线重连后的消息回拉 fallback |
#### 必须解决
- 取代单 spinner按阶段显示
- Failed task 显示原因
- WS 断线重连后能拉回历史消息
#### 加分项
- Typing indicatorgenerating 阶段的逐字感)
- 全局任务进度 FAB
- Stop 按钮的明确反馈
---
## 八、验收标准
### 阶段 0
- [ ] `deriveAgentPresence` / `deriveRuntimeHealth` 单测覆盖所有分支 + 边界 caseruntime null / tasks 空 / 时钟边界)
- [ ] 控制台调用 `useActiveTasks(wsId)` 能拿到数据
- [ ] WS 事件接线表里每个事件都能正确 invalidate手测
- [ ] 桌面端关本机 daemon 后 runtime cache 在 1s 内变 offline
### 阶段 1
- [ ] Daemon 关闭后Agent 列表项 75 秒内变成 Offline 灰点
- [ ] Agent 跑任务时,列表项变成 Working 蓝点;排队 N 个时带 `+N` 角标
- [ ] Agent 任务失败后,列表项 2 分钟内显示 Failed 红点 + tooltip 含失败原因2 分钟后自动恢复
- [ ] Runtime 列表能区分 Online / Recently Lost / Offline / About to GC 四态
- [ ] Runtime 列表行展示关联 agent 数 + 当前任务数
- [ ] 桌面端本机 daemon 卡片视觉跟云端 runtime 列表项一致
- [ ] 全局 grep `agent.status` 在 views 层无引用
### 阶段 2
- [ ] Agent 详情头部状态跟列表一致
- [ ] Profile card 状态行 + runtime 行不再自相矛盾
- [ ] Runtime 详情页能在不展开图表的前提下看到本期成本
- [ ] Token usage API 按选中的时间窗口拉(不再总是 90 天)
### 阶段 3
- [ ] 同一 issue 多 agent 工作时,每个 agent 一行实时状态
- [ ] queued / dispatched / running 三态视觉差异清晰
- [ ] 任务失败时,行内显示中文 failure reason + 处理建议
- [ ] 不支持 live log 的 provider 显式说明"等任务完成后查看结果"
### 阶段 4
- [ ] 所有展示 agent 头像的位置 hover 都能看到完整状态卡
- [ ] 渲染 30+ agent 头像的页面hover 不触发任何新 HTTP 请求
### 阶段 5
- [ ] Chat 中能看到任务在哪个阶段
- [ ] WS 断线后重连能补齐历史消息
- [ ] Failed task 显示原因
---
## 九、边界与不做的事
明确**不在本次范围内**
1. **不改后端 schema**——`agent.status` / `blocked` / `error` 字段保留(迁移风险大,无收益)
2. **不让后端做派生**——所有派生在前端做(迭代速度 + UI 解耦)
3. **不新增 WS 事件类型**——只订阅现有但未用的(如 `agent:status`
4. **不动 `agent_runtime.status` 的 online/offline 二态**——前端从这两态 + `last_seen_at` 派生四态
5. **不重构 Skills 界面**——它已经是参考样板
6. **Phase 5 不做"逐字流式渲染"**——后端 stream-json 是批量推送typing indicator 是视觉技巧
7. **不做"agent 健康综合评分"**——只暴露原始信号,不算综合分
8. **不为 CLI 未安装等场景引入第六态**——归到 offline 的 tooltip 子分类
9. **不在 Zustand 里存派生结果**——server data 不能复制进 store
---
## 十、风险与注意事项
### 10.1 WS 事件覆盖完整性
派生函数的"实时性"靠的是依赖 query 都被 WS 正确 invalidate。一个事件没接好状态就会卡住。
**缓解**:阶段 0 的接线表是必须维护的"契约"。每次新增派生维度,回头检查这张表,确保新依赖的字段有事件覆盖。
### 10.2 时钟一致性
Failed 状态依赖客户端时间和 `completed_at` 的差。客户端时钟漂移会让 2 分钟窗口不准。
**缓解**2 分钟 ± 30 秒不影响判断,可接受。需要时可用 server 在 WS 心跳里携带的时间作为参考。
### 10.3 旧字段引用残留
`statusConfig` 删除后,遗漏的引用会运行时错误。
**缓解**
- TypeScript 严格模式 + 改类型
- 全局 grep 验证
- 长期防御:在 `packages/core/agents/types.ts``agent.status` 从外部消费的 Agent 类型里 omit 掉,仅保留在 RawAgent 内部类型里给 API 层用
### 10.4 跨界面状态一致性
不同地方调用同一个 agent 的派生函数,必须结果一致。
**缓解**:所有调用方走 `useAgentPresence(agentId)` 这个唯一 hook不允许直接调派生函数。Hook 集中管理输入数据收集。
### 10.5 active-tasks 数据量
如果工作区某天有 1000+ 活跃任务,全工作区一次拉的设计会受影响。
**缓解**
- 当前活跃任务有天然上限(受 `max_concurrent_tasks` × agent 数量约束)
- 监控加上cache 大小超过阈值时上报
- 必要时再考虑 windowing最近 N 个)或 server 端聚合
---
## 十一、参考
- [产品全景文档](./product-overview.md) —— Agent / Runtime / Daemon 的产品定位
- [Skills 界面源代码](../packages/views/skills/) —— 设计语言样板
- 关键 PR#1607Skills 重设计)、#1614Card + PageHeader#1618(描述恢复)、#1610Dialog 闪烁修复)
- 后端关键代码:
- `server/internal/service/task.go` —— `agent.status` 更新逻辑(`ReconcileAgentStatus`
- `server/cmd/server/runtime_sweeper.go` —— Runtime 心跳 / sweeper 时间常量
- `server/migrations/055_task_lease_and_retry.up.sql` —— Task `failure_reason` 五态
- `server/migrations/037_fix_pending_task_unique_index.up.sql` —— 一个 issue 多 agent 处理的设计依据
---
## 附录 A当前现状清单保留作为重设计前的存档
> 这部分原本在 design-brief.md 里,迁移过来作为重设计前的现状记录。设计师在画稿前可以贴上当前界面截图作为对照。
### A.1 Agent 字段可见性
| 字段 | 当前状态 | 备注 |
|---|---|---|
| `name` / `avatar_url` / `description` | ✅ | 列表 + 详情都展示 |
| `archived_at` | ✅ | 列表项灰显 + 详情头部 banner |
| `status`idle/working/... | ✅ 但语义错误 | **要替换成派生 5 态** |
| `runtime_mode`local/cloud | ✅ 图标 | 列表项右侧 Cloud / Monitor 图标 |
| `instructions` | ✅ | Instructions tab |
| `custom_env` / `custom_env_redacted` | ✅ | Env tab |
| `custom_args` | ✅ | Custom Args tab |
| `visibility`workspace / private | ✅ | Settings tab |
| `max_concurrent_tasks` | ✅ | Settings tab |
| `model` | ✅ | Settings tab |
| `runtime_id` 关联 | ✅ | Settings tab |
| `skills` | ✅ | Skills tab + profile card 前 3 个 |
| `owner_id` | ✅ | Profile card |
| `created_at` / `updated_at` | 🆕 | 后端有UI 完全没展示 |
| `archived_by` | 🆕 | 后端有UI 隐藏 |
### A.2 Runtime 字段可见性
| 字段 | 当前状态 | 备注 |
|---|---|---|
| `name` | ✅ | 列表 + 详情头部 |
| `provider` | ✅ Logo | 9 种 |
| `runtime_mode` | ✅ 文字 | `RuntimeModeIcon` 组件存在但从未被调用 |
| `status`online/offline | ✅ 圆点 + 徽章 | 离线圆点浅色主题下几乎不可见 |
| `last_seen_at` | ✅ 仅详情页 | **列表完全看不到** |
| `device_info` | ✅ 详情页 | 没有人类可读化 |
| `daemon_id` | ✅ mono 字体 | 不可复制 |
| `metadata.cli_version` | ✅ | CLI 更新部分 |
| `metadata.launched_by` | ✅ | "Managed by Desktop" |
| `owner_id` | ✅ | 头像 + 名字 |
| `created_at` / `updated_at` | ✅ | ISO 时间戳 |
### A.3 桌面端 IPC 数据
```
DaemonStatus (本地 IPC)
├─ state: running / stopped / starting / stopping / installing_cli / cli_not_found
├─ pid, uptime
├─ daemonId, deviceName, serverUrl
├─ agents: 当前运行的 agent IDs
├─ workspaceCount
└─ profile
```
### A.4 截图占位区
设计师拿到这份文档后,请把以下界面的当前截图贴在对应位置:
- **Agents 界面**:列表页 / 详情页(每 tab 一张)/ 创建对话框
- **Runtimes 界面**:列表页 / 详情页(含 5 个图表)/ 桌面端 daemon 卡片
- **Issue Detail**:无任务执行时 / 单 agent 执行中 / 多 agent 并发 / 全屏 transcript dialog
### A.5 Token Usage 5 个图表的数据细节
Runtime 详情页底部 token usage 部分5 个图表 + 1 张表格全部展开,没有主次。阶段 2 改造时按下表理解每个图表的数据契约:
| 图表 | 数据源 | 时间粒度 | 维度 | 度量 |
|---|---|---|---|---|
| **Activity Heatmap** | `getRuntimeUsage?days=90` | 日 | date | 4 级强度,按 token 总量百分位分级 |
| **Hourly Activity** | `getRuntimeTaskActivity` | 小时0-23 | hour | 任务数 |
| **Daily Token Chart** | 同 Heatmap客户端聚合 | 日 | date | input/output/cacheRead/cacheWrite 总和 |
| **Daily Cost Chart** | 同上 + 客户端定价计算 | 日 | date | 美元成本,按 model pricing 表 |
| **Model Distribution** | 同上聚合 by model | 全周期 | model | tokens 占比 + cost |
**当前实现的问题**API 总是取 90 天数据,客户端做 7d/30d 过滤——浪费服务端资源,首次加载慢。改造时按选中窗口拉。
### A.6 Issue Detail 任务展示的当前实现
主要组件:`packages/views/issues/components/agent-live-card.tsx`
#### 已有能力
- 多 agent 并发执行同一 issue 时,第一个卡片是 sticky顶部固定其他在下方滚动
- 每个 task 卡片可展开 timeline显示 tool_use / tool_result / thinking / text / error 五种消息类型
- 实时滚动WS 事件 `task:message` 到达时追加 timeline
- 工具调用计数 badge
- Stop 按钮可取消任务
- 全屏 transcript dialog支持事件类型筛选 + 复制)
- 已完成/失败/取消的任务进入 TaskRunHistory 折叠区
#### 数据来源
| 数据 | API |
|---|---|
| 当前 issue 的活跃 task 列表 | `getActiveTasksForIssue(issueId)` |
| 每个 task 的历史消息 | `listTaskMessages(taskId)` |
| 实时消息流 | WS `task:message` |
| 状态变化 | WS `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` |
| 取消任务 | `cancelTask(issueId, taskId)` |
阶段 3 在此基础上重构成"每个 agent 一行的全景视图"。

View File

@@ -1,530 +0,0 @@
# Agents & Runtimes 界面重设计 · 设计师 Brief
> **文档定位**:这份文档专门给负责重设计 Agents 和 Runtimes 界面的 UI/UX 设计师。
>
> 看完这份文档,你应该能:
> - 理解我们在解决什么问题、目标体感是什么
> - 知道每个界面有哪些数据可用、哪些是新展示的、哪些需要工程补
> - 知道有哪些现有交互不能动(避免破坏用户已建立的习惯)
> - 知道可以参考哪些已经做完的设计Skills 是样板)
> - 直接进入设计稿环节
>
> **必读伴侣文档**[Agent / Runtime 状态系统重设计](./agent-runtime-status-redesign.md) —— 完整的工程方案、状态规范、实施阶段。本 brief 是它的"设计师视角切片"。
---
## 目录
1. [一句话目标](#一一句话目标)
2. [必读:状态视觉规范](#二必读状态视觉规范)
3. [Agents 界面](#三agents-界面)
4. [Runtimes 界面](#四runtimes-界面)
5. [跨界面统一Agent Hover Card](#五跨界面统一agent-hover-card)
6. [跨平台差异处理](#六跨平台差异处理)
7. [设计语言参考Skills 界面](#七设计语言参考skills-界面)
8. [工程会同步交付的能力](#八工程会同步交付的能力)
9. [设计师产出清单](#九设计师产出清单)
10. [附录:截图占位区](#十附录截图占位区)
---
## 一、一句话目标
**当前所有界面都在直接展示后端字段,缺少"用户视角"的状态翻译层。**
用户看到 `Idle` / `Online` / spinner但这些词没有回答"agent 现在能不能用 / 在做什么 / 出问题了没"。我们要做的是一套**用户视角的状态系统**:让用户一眼就知道一个 agent / runtime 的健康状况,跨界面一致。
---
## 二、必读:状态视觉规范
这套规范是所有界面的共同基础。**先输出这套规范,再画具体界面**。
### 2.1 Agent 五态
| 状态 | 颜色 | 用户语义 | 出现条件 |
|---|---|---|---|
| **Available** | 🟢 绿 | 在线空闲,可以接活 | runtime 在线 + 没有活跃任务 |
| **Working** | 🔵 蓝(品牌色) | 正在干活 | runtime 在线 + 至少一个任务在执行 |
| **Pending** | 🟡 黄 | 任务排着但没在跑 | runtime 在线 + 0 个执行中 + ≥1 个排队 |
| **Failed** | 🔴 红 | 最近一次失败 | 最近 2 分钟内有任务失败 |
| **Offline** | ⚫ 灰 | Daemon 离线,不可用 | runtime 离线 |
**复合维度**:当 agent 是 Working 但同时有任务排队,主状态保持 Working旁边带 `+N` 角标("还有 N 个排队")。
**Failed 状态特别说明**:失败显示**保持 2 分钟**,之后自动恢复(避免红点黏太久)。设计上要表达"这是临时强提示"。
### 2.2 Runtime 四态
| 状态 | 触发条件 | 用户语义 |
|---|---|---|
| **Online** | 最近 45 秒内有心跳 | 健康 |
| **Recently Lost** | 离线但 < 5 分钟 | 可能短暂网络抖动 |
| **Offline** | 离线 5 分钟 ~ 7 天 | 长期离线,需排查 |
| **About to GC** | 离线接近 7 天阈值 | 系统将自动清理 |
> **关于 CLI 未安装等"runtime 在线但跑不了"的场景**:归并到 Offlinetooltip 写明具体原因("CLI 未安装"、"Daemon 启动中")。**不要为这些子情况新设状态色**,色彩枚举太多反而失去信号。
### 2.3 视觉表达分三层
每种状态在以下三个层级要保持一致:
- **Dot**(圆点):列表项、头像旁的小圆点,最紧凑场景
- **Badge**(徽章):详情页头部、卡片角落,带图标 + 文字
- **Tooltip / Hover Card**:鼠标悬停时展开完整信息
**跨界面一致性**:同一个 agent无论出现在哪agents 列表 / issue assignee picker / autopilot 编辑 / chat 选择面板 / 评论 @),状态视觉**必须完全一致**。这是这次设计能立住的关键。
---
## 三、Agents 界面
### 3.1 当前界面长什么样
主要文件:
- `packages/views/agents/components/agents-page.tsx` — 列表页容器
- `packages/views/agents/components/agent-list-item.tsx` — 列表项
- `packages/views/agents/components/agent-detail.tsx` — 详情页(含 tabsInstructions / Skills / Tasks / Environment / Custom Args / Settings
- `packages/views/agents/components/agent-profile-card.tsx` — 详情顶部的 profile 卡片
- `packages/views/agents/components/create-agent-dialog.tsx` — 创建对话框
**当前的视觉语言跟 Skills 重设计前一样——这次目标是迁到 Skills 风格。**
### 3.2 当前的核心痛点
按用户感知严重度排列:
1. **列表上的 "Idle" 绿点会骗人**——daemon 已经死了agent 仍然显示 Idle。用户分配任务后没有任何反馈。**根因**`agent-list-item.tsx` 完全忽略 runtime 在线性,只读 `agent.status` 这个后端字段。
2. **`agent-profile-card` 状态行和 runtime 行不联动**——状态显 Idleruntime 显 Offline自相矛盾。
3. **跨界面状态展示不一致**——issue assignee picker、autopilot picker、chat 选择 agent 时**完全不显示状态**,用户做选择时不知道哪个能用。
4. **创建 agent 时无法预知能否立即跑起来**——dialog 显示了 runtime 在线点,但不知道 model 是否合法、初始化会不会失败。
5. **Archived agent 和 active agent 混在同一列表**——切换视图全靠手动按钮,不够清晰。
6. **零 WS 订阅**——离线/上线必须手动刷新页面才能感知。
### 3.3 重设计目标
#### 必须达成
- 列表项一眼能看出 agent 是否真的可用(融合 runtime 在线性的派生 5 态)
- Hover card 内必须看到派生状态、当前任务数、runtime 健康、最近失败原因
- 跨界面一致:所有显示 agent 头像的位置都用同一个 hover card详见第 5 节)
- 创建/编辑 agent 时,能预看"如果保存agent 会变成什么状态"
- 实时更新——状态变化 75 秒内反映到 UI
#### 加分项
- Archived agent 独立 tab/折叠区,不再和 active 混在一起
- 列表支持按"最近活动时间"排序
- 支持按状态筛选("显示所有 Failed"、"显示所有 Pending"
### 3.4 可用数据清单(设计稿可以放心假设可用)
> **图例**:✅ = 当前已展示;🆕 = 已可用但当前 UI 没用;🔧 = 工程会在阶段 0 补上;📡 = 实时事件可用
#### Agent 主体字段(来自 `Agent` type
| 字段 | 类型 | 当前状态 | 说明 |
|---|---|---|---|
| `name` / `avatar_url` / `description` | string | ✅ | 已展示 |
| `archived_at` / `archived_by` | string | ✅ / 🆕 | 列表灰显archived_by 后端有但 UI 隐藏 |
| `runtime_mode` | "local" / "cloud" | ✅ 图标 | Cloud / Monitor 图标区分 |
| `instructions` | string | ✅ | Instructions tab |
| `custom_env` / `custom_env_redacted` | KV / bool | ✅ | Env tab权限控制隐藏值 |
| `custom_args` | string[] | ✅ | Custom Args tab |
| `visibility` | "workspace" / "private" | ✅ | Settings tab |
| `max_concurrent_tasks` | number | ✅ | Settings tab默认 6 |
| `model` | string | ✅ | Settings tab |
| `runtime_id` | string | ✅ 选择器 | Settings tab |
| `skills` | Skill[] | ✅ | Skills tab + profile card 前 3 个 |
| `owner_id` | string | ✅ | Profile card 显示名字 |
| `created_at` / `updated_at` | string | 🆕 | **后端有UI 完全没展示** —— 可以做"最近创建"、"最近修改"标签 |
| ~~`status`~~idle/working/blocked/error/offline | enum | ✅ 但**已废弃**展示 | 后端字段保留但 UI 完全不读 |
#### 派生数据(工程在阶段 0 提供,设计稿可放心引用)
| 派生信息 | 来源 |
|---|---|
| **Agent 派生 5 态状态** | 🔧 由 agent + runtime + active tasks 派生 |
| **当前 running 任务数** | 🔧 派生 |
| **当前 queued 任务数** | 🔧 派生 |
| **最近一次失败的原因**5 种 enum | 🔧 派生 |
| **关联 runtime 健康状态** | 🔧 派生 |
| **runtime last_seen 相对时间** | 🔧 已有工具函数 |
**失败原因 5 个枚举(中文文案待你定)**
- `agent_error` — Agent 执行报错
- `timeout` — 执行超时
- `runtime_offline` — Daemon 离线
- `runtime_recovery` — Daemon 重启回收
- `manual` — 用户取消
每种原因用户的处理方式不同UI 应给出对应建议(你来设计文案)。
#### 实时事件WS
| 事件 | 状态 | 用途 |
|---|---|---|
| `agent:status` | 📡 后端发,工程接 | agent 字段变化 |
| `agent:created` / `agent:archived` / `agent:restored` | 📡 工程接 | 列表增删 |
| `task:dispatch` / `task:completed` / `task:failed` / `task:cancelled` | 📡 工程接 | 状态派生关键信号 |
| `daemon:register` | 📡 工程接 | runtime 上下线 |
设计上**不需要为"加载/loading"过度设计**——状态变化是实时的,几乎不会出现"等数据"的 loading 态。
### 3.5 不能动的现有交互(必须保留)
- 创建 agent dialog 的 chooser → 表单两步流(见 Skills 的 `create-skill-dialog`
- 各 tab 的编辑能力Instructions / Env / Custom Args / Settings 全部可编辑
- Archive / Restore 操作
- Tasks tab展示该 agent 的历史 task 列表(按状态分组:活跃 → 完成)
- Skills tab可挂载/卸载 skills
### 3.6 关键问题留给你定的
1. **Archived agent 怎么收纳**:独立 tab、折叠区、还是 segment 切换?
2. **状态筛选**是 chips 还是 dropdown
3. **Failed 红点的 2 分钟动效**:脉冲?颜色渐变?
4. **复合状态Working + 排队 N**角标位置dot 旁 / 头像下角 / Badge 内嵌)?
5. **创建 dialog 的"预览状态"**:怎么不打扰主流程的同时让用户知道"这个 agent 创建出来会是什么色"
---
## 四、Runtimes 界面
### 4.1 当前界面长什么样
主要文件:
- `packages/views/runtimes/components/runtimes-page.tsx` — 容器,含 owner filtermine/all
- `packages/views/runtimes/components/runtime-list.tsx` — 列表
- `packages/views/runtimes/components/runtime-detail.tsx` — 详情头部
- `packages/views/runtimes/components/usage-section.tsx` — Token usage 主区
- `packages/views/runtimes/components/charts/` — 5 个图表组件
- `packages/views/runtimes/components/update-section.tsx` — CLI 更新流程
- `apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx` — 桌面端独有:本机 daemon 卡片(通过 IPC独立于 server runtime 列表)
### 4.2 当前的核心痛点
按严重度排列:
1. **离线圆点几乎不可见**——浅色主题下 `bg-muted-foreground/40` 视觉上消失。
2. **列表无 last_seen**——无法区分"刚断线 5 分钟"和"3 个月前断线"。
3. **看不到 runtime 服务了哪些 agent / 当前有几个 task 在跑**——runtime 在用户心智里成了"孤岛"。
4. **7 天 GC 阈值无任何 UI 提示**——runtime 突然消失,用户不知道为什么。
5. **桌面端 daemon 卡片和云端 runtime 卡片视觉分裂**——同一台机器同一概念,两套设计。
6. **Token usage 信息过载**——5 个图表 + 1 张表全部展开,普通用户找不到"本月花了多少钱"。
7. **无 ping / 诊断按钮**——遇到断线没法主动验证。
8. **`RuntimeModeIcon` 死代码**——本地 vs 云端在列表项里没有图标区分。
### 4.3 重设计目标
#### 必须达成
- 列表项一眼区分四态Online / Recently Lost / Offline / About to GC
- 列表项展示**关联 agent 数量** + **当前任务数**runtime 不再是孤岛)
- 桌面端 daemon 卡片和云端 runtime 卡片用统一视觉语言
- Token usage 区分主次核心指标本期成本、token 总量)放顶部,详细图表折叠或下沉
#### 加分项
- Runtime 健康综合评分(在线 + 心跳新鲜度 + 任务负载)
- 7 天 GC 倒计时提示
- Ping / 诊断按钮
- 高使用量 runtime 的视觉强调(成本警告、利用率热图 sparkline
- Local vs Cloud 图标区分(启用废弃组件 `RuntimeModeIcon`
### 4.4 可用数据清单
#### Runtime 主体字段(来自 `RuntimeDevice` type
| 字段 | 类型 | 当前状态 | 说明 |
|---|---|---|---|
| `name` | string | ✅ | 列表 + 详情头部 |
| `provider` | string | ✅ Logo | 9 种claude / codex / opencode / openclaw / hermes / gemini / pi / cursor 等 |
| `runtime_mode` | "local" / "cloud" | ✅ 文字 | 列表项里没图标(死代码) |
| `status` | "online" / "offline" | ✅ 圆点 | 浅色主题下离线几乎不可见 |
| `last_seen_at` | string | ✅ 仅详情页 | **列表完全看不到** |
| `device_info` | string | ✅ 详情页 | 显示原始字符串如 `darwin-arm64`,无人类可读化 |
| `daemon_id` | string | ✅ mono 字体 | 不可复制 / 不可点击 |
| `metadata.cli_version` | string | ✅ | CLI 更新部分用 |
| `metadata.launched_by` | string | ✅ | 桌面端启动时显示 "Managed by Desktop" |
| `owner_id` | string | ✅ | 头像 + 名字 |
| `created_at` / `updated_at` | string | ✅ | 详情页底部 ISO 时间戳 |
#### 派生数据(工程阶段 0 提供)
| 派生信息 | 来源 |
|---|---|
| **Runtime 4 态健康** | 🔧 由 status + last_seen_at 派生 |
| **服务的 agent 列表 + 数量** | 🔧 前端 joinagent 持有 runtime_id列表数据已在 cache |
| **当前在跑 task 数** | 🔧 前端 filter active-tasks |
| **last_seen 相对时间字符串**"5 minutes ago" | 🔧 工具函数已有 |
#### Token Usage 5 个图表的数据契约
| 图表 | 数据源 | 时间粒度 | 维度 | 度量 |
|---|---|---|---|---|
| **Activity Heatmap** | `getRuntimeUsage?days=90` | 日 | date | 4 级强度,按 token 总量百分位分级 |
| **Hourly Activity** | `getRuntimeTaskActivity` | 小时0-23 | hour | 任务数 |
| **Daily Token Chart** | 同 Heatmap客户端聚合 | 日 | date | input/output/cacheRead/cacheWrite 总和 |
| **Daily Cost Chart** | 同上 + 客户端定价 | 日 | date | 美元成本 |
| **Model Distribution** | 同上聚合 by model | 全周期 | model | tokens 占比 + cost |
**当前实现的问题**API 总是取 90 天数据,客户端做 7d/30d 过滤——浪费服务端资源。改造时按选中窗口拉。
#### 实时事件
| 事件 | 状态 |
|---|---|
| `daemon:register` | 📡 已订阅,触发列表刷新 |
| `daemon:heartbeat` | 📡 后端发但前端**故意忽略**(防过度刷新)。设计时假设状态变化最坏 75 秒内可见 |
#### 桌面端独有的本机 IPC 数据(仅桌面端可见)
```
DaemonStatus (本机 IPC亚秒级实时)
├─ state: running / stopped / starting / stopping / installing_cli / cli_not_found
├─ pid, uptime, daemonId, deviceName, serverUrl
├─ agents: 当前运行的 agent IDs
├─ workspaceCount
└─ profile
```
工程会把这份数据自动喂进 cache**设计上不要为"桌面端有更多数据"做特殊视觉**——派生状态对设计师而言是统一的,只是桌面端响应更快。但桌面端**对自己的本机 daemon 有更多操作能力**(见第 6 节)。
### 4.5 不能动的现有交互(必须保留)
- Owner filtermine/all toggle
- Delete runtime 操作(含权限检查)
- CLI 更新流程(`update-section.tsx`):检查更新、触发更新、查看更新状态
- 5 个图表的数据展示(信息架构可以重排,但数据本身要保留)
- 桌面端start/stop/restart 本机 daemon 的按钮
### 4.6 关键问题留给你定的
1. **列表项到底放多少信息**last_seen + agent count + active tasks 放哪?避免拥挤
2. **桌面端 daemon 卡片和云端 runtime 卡片"视觉对齐"的尺度**100% 同模板?还是同卡片框 + 内容差异化?
3. **Token usage 主次怎么排**:本期成本数字 + 单图表 + "查看更多" 折叠?或者 dashboard 化?
4. **About to GC 怎么提示**横幅badge倒计时
5. **Ping / 诊断按钮的位置**:详情页头部?右上角菜单?
6. **关联 agent 列表展示**:堆叠头像?文字 "3 agents"?还是子区块?
---
## 五、跨界面统一Agent Hover Card
### 5.1 现有组件
`packages/views/agents/components/agent-profile-card.tsx`——已经存在,但只在 Agents 主页 hover 时出现。
这次会把它升级成**统一 hover card**,挂到所有展示 agent 头像的地方。
### 5.2 必须出现的位置
| 位置 | 当前状态 |
|---|---|
| Agents 列表 / 详情 | ✅ 已有 |
| Issue Assignee Picker | ❌ 仅头像无状态 |
| Issue Detail 头部 assignee | ❌ 仅头像无状态 |
| Issue 列表 / 看板的分配头像 | ❌ 仅头像 |
| Autopilot 列表 / 编辑assignee | ❌ 仅头像 |
| Project lead picker | ❌ 仅头像 |
| Chat 选择 agent 面板 | ❌ 待确认 |
| 评论里的 @agent | ❌ 仅头像 |
### 5.3 卡片必须显示什么(按重要度)
1. **派生 5 态状态**(不是 `agent.status` 原始值)
2. **Runtime 健康**:在线性 + last_seen 相对时间
3. **当前任务**N running / M queued
4. **最近失败**(如果有):原因 + 时间
5. **Agent 名称 + description**
6. **关联 skills**(前 3 个 + `+N`
7. **Owner**
### 5.4 设计要点
- 卡片宽度跨多种使用场景要适配issue 列表很窄、设置页很宽)
- 触发延迟hover delay跟 Skills 已有的卡片保持一致
- 暗色主题下信息层级要清晰
---
## 六、跨平台差异处理
### 6.1 状态视觉是平台无关的
派生 5 态 / 4 态、视觉规范、hover card——**两端共享同一套设计**。设计稿只画一份。
### 6.2 数据响应速度差异(不影响视觉)
| 平台 | Runtime 状态变化感知延迟 |
|---|---|
| Web | 最坏 75 秒 |
| Desktop看自己机器 | < 1 秒IPC |
| Desktop看别人机器 | 最坏 75 秒(跟 Web 一样) |
设计上不需要透出"快慢"——用户感知不到这是 IPC 还是 server。
### 6.3 操作能力差异(影响按钮可见性)
| 操作 | Web | Desktop自己机器 | Desktop别人机器 |
|---|---|---|---|
| 看状态 | ✅ | ✅ | ✅ |
| 重启 daemon | ❌ | ✅ | ❌ |
| 看 daemon logs | ❌ | ✅ | ❌ |
| 看 CLI 安装详情 | ❌ | ✅ | ❌ |
**设计要点**:操作按钮在不该有权限的位置应该**直接隐藏**,不要灰显(避免视觉噪音)。
### 6.4 桌面端独有的"本机 daemon 卡片"
当前桌面端有一个独立卡片显示本机 daemon。重设计后
- 视觉上跟云端 runtime 列表项**用同一套视觉语言**
- 但承载更多本地操作(重启、看日志、安装 CLI、profile 切换)
- 位置:列表顶部 sticky / 列表头部突出 / 右侧独立 panel —— 由你定
---
## 七、设计语言参考Skills 界面
### 7.1 Skills 是这次重设计的视觉锚点
Skills 界面已经在 2026-04 完成重设计PR #1607#1614#1618#1610)。这次 Agents 和 Runtimes **直接照搬 Skills 的视觉语言**,保持产品体感一致。
参考目录:`packages/views/skills/`
### 7.2 必须复用的 10 条规则
1. **统一页头** `PageHeader`h-12 + mobile sidebar trigger
2. **响应式网格列表**`grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.2fr)_minmax(0,6rem)_auto]`,不用 flexbox
3. **每行三层信息**主标题font-medium→ 描述line-clamp-1 muted→ 元数据xs muted
4. **关联对象用头像堆栈**:最多 3 + `+N`size=22 + `ring-2 ring-background` + `-space-x-1.5`
5. **卡片化列表 + 卡片内工具栏**:搜索和 scope tab 在 `CardToolbar`h-12不在页面级
6. **创建用多步 dialog**chooser → 表单可回退Dialog 宽度按方法切换manual/url 用 `!max-w-md`runtime 用 `!max-w-2xl`300ms 平滑过渡
7. **空状态 / 筛选无结果分别有详细文案**:图标 + 标题 + 三行说明 + 清晰 CTA
8. **长列表加 `useScrollFade`**:滚动容器上下边缘淡出
9. **头像统一用 `ActorAvatar`**:传 `size`,自动支持 agent / 人员
10. **权限检查 hook 化**`useCanEdit...`UI 提前隐藏/禁用操作按钮
---
## 八、工程会同步交付的能力
阶段 0数据层地基完成后**设计稿可以放心假设以下能力都到位**
- 任意位置都能拿到一个 agent 的派生 5 态状态
- 任意位置都能拿到 agent 的当前任务数running / queued
- 任意位置都能拿到 agent 关联的 runtime 4 态健康
- 任意位置都能拿到 runtime 服务的 agent 列表 + 数量
- 任意位置都能拿到 runtime 当前任务数
- 状态变化是实时的(订阅 WS 事件后会自动更新)
- 桌面端会自动获得"亚秒级"响应——不需要为此画两套稿
### 已有 API不需要新加可放心引用
- `listAgents` / `getAgent` / `createAgent` / `updateAgent` / `archiveAgent` / `restoreAgent`
- `listAgentTasks(agentId)` — 单 agent 历史任务
- `listRuntimes` / `deleteRuntime`
- `getRuntimeUsage(runtimeId, { days })` — token 用量
- `getRuntimeTaskActivity(runtimeId)` — 小时级活动
### 工程要在阶段 0 补的(设计稿可以假设有,但要知道这是新增)
- **后端**`GET /api/workspaces/:slug/active-tasks` — 全工作区活跃任务一次拉
- **后端**:诊断 / Ping API如果你的设计稿用到工程要评估优先级
- **前端类型**`AgentTask.failure_reason` 字段5 枚举agent_error / timeout / runtime_offline / runtime_recovery / manual暴露到前端类型
- **前端**:派生函数(`deriveAgentPresence` / `deriveRuntimeHealth`+ 全工作区 active-tasks query + WS 接线
### 工程不会做的(设计稿不要假设有)
- 不引入"agent 健康综合评分"——只暴露原始信号
- 不做"从历史 task 自动推断 agent 类型"等 AI 派生
- 不为 runtime 引入新状态色(稳定在 4 态)
- 不做后端聚合 API除了 active-tasks 这个补全)
---
## 九、设计师产出清单
按优先级排列,**P0 必须先于 P1**。
### P0 — 状态视觉规范(基础,所有界面共用)
- 5 态颜色 tokenAvailable / Working / Pending / Failed / Offline
- 4 态颜色 tokenOnline / Recently Lost / Offline / About to GC
- Dot / Badge / Tooltip 三层视觉规范
- 复合维度Working + 排队角标)的视觉表达
- Failed 状态的 2 分钟时间窗口动效(强提示 + 自动消失)
### P0 — Agents 界面
- 列表页(含派生状态、关联 runtime 健康、最近活动时间)
- 详情页头部 + Profile Card状态行联动
- 创建对话框(保留两步流,加入 runtime 在线状态预览)
### P0 — Runtimes 界面
- 列表页(暴露 last_seen、关联 agents、当前 task 数)
- 详情页头部4 态 badge、device info 人类可读化)
- Token usage 信息架构重整:核心指标置顶,详细图表下沉/折叠
### P1 — Hover Card 跨界面统一
- 一个适配多场景宽度的卡片设计
- 7 项内容的信息层级
- hover 触发交互(与 Skills 一致)
### P1 — 桌面端本机 daemon 卡片
- 视觉对齐云端 runtime 卡片
- 本机操作按钮(重启 / 日志 / CLI 安装)的位置
- Profile 切换(如果做多 profile
### P2 — 加分项
- Runtime 健康综合评分(视觉化)
- 7 天 GC 倒计时
- 高使用量 runtime 的成本警告
- Local vs Cloud 图标区分
- Agent archived 独立 tab/折叠区
- 列表按"最近活动"排序、按状态筛选
---
## 十、附录:截图占位区
> 设计师拿到这份文档后,请把以下三个界面的当前截图贴在对应位置,作为重设计前的现状记录,方便对比。
### 10.1 Agents 界面
- 列表页截图__待贴__
- 详情页截图(每个 tab 一张Instructions / Skills / Tasks / Environment / Custom Args / Settings__待贴__
- Profile Card 截图__待贴__
- 创建对话框截图chooser + form 两步__待贴__
- Hover card 当前样式截图__待贴__
### 10.2 Runtimes 界面
- 列表页截图mine / all 两态__待贴__
- 详情页截图(含 5 个图表全部展开__待贴__
- 桌面端 daemon 卡片截图__待贴__
- CLI 更新流程截图__待贴__
### 10.3 跨界面 agent 头像出现的位置
- Issue Assignee Picker__待贴__
- Issue Detail 头部 assignee__待贴__
- Issue 列表 / 看板__待贴__
- Autopilot 列表 / 编辑__待贴__
- Project lead picker__待贴__
- Chat agent 选择面板__待贴__
- 评论 @agent__待贴__
---
## 参考文档
- [Agent / Runtime 状态系统重设计(主文档)](./agent-runtime-status-redesign.md) — 完整工程方案、状态规范、实施阶段
- [产品全景文档](./product-overview.md) — 理解 agent / runtime / daemon 在整个产品里的位置
- [Skills 界面源代码](../packages/views/skills/) — 直接参考的设计语言样板
- 相关 PR#1607Skills 重设计)、#1614Card + PageHeader#1618(描述恢复)、#1610Dialog 闪烁修复)

View File

@@ -0,0 +1,4 @@
// User-facing limits enforced symmetrically on the front-end (UI counter +
// disabled save) and the back-end (handler validation + DB CHECK constraint).
// Kept in core so both apps and the test suite read from one source.
export const AGENT_DESCRIPTION_MAX_LENGTH = 255;

View File

@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import type { Agent, AgentRuntime, AgentTask, TaskFailureReason } from "../types";
import type { Agent, AgentRuntime, AgentTask } from "../types";
import {
buildPresenceMap,
deriveAgentAvailability,
deriveAgentPresenceDetail,
deriveLastTaskState,
deriveWorkload,
deriveWorkloadDetail,
} from "./derive-presence";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
@@ -80,8 +81,7 @@ function makeTask(overrides: Partial<AgentTask> = {}): AgentTask {
describe("deriveAgentAvailability", () => {
// Reachability dimension only — runtime + clock decide it; tasks are
// irrelevant. The whole point of splitting from LastTaskState is that
// these tests can ignore task fixtures entirely.
// irrelevant to this axis.
it("returns online when runtime is fresh-online", () => {
expect(deriveAgentAvailability(makeRuntime(), NOW)).toBe("online");
@@ -120,128 +120,111 @@ describe("deriveAgentAvailability", () => {
});
});
describe("deriveLastTaskState", () => {
// Task dimension only — runtime status is not consulted.
describe("deriveWorkload", () => {
// Atomic 3-way classifier — used by both Agent (per-agent task counts)
// and Runtime (per-runtime aggregated counts). Pure functional mapping
// from a count pair to a workload label.
it("returns working when runningCount > 0", () => {
expect(deriveWorkload({ runningCount: 1, queuedCount: 0 })).toBe("working");
expect(deriveWorkload({ runningCount: 3, queuedCount: 5 })).toBe("working");
});
it("returns queued when nothing running but queuedCount > 0", () => {
expect(deriveWorkload({ runningCount: 0, queuedCount: 1 })).toBe("queued");
expect(deriveWorkload({ runningCount: 0, queuedCount: 5 })).toBe("queued");
});
it("returns idle when both counts are zero", () => {
expect(deriveWorkload({ runningCount: 0, queuedCount: 0 })).toBe("idle");
});
});
describe("deriveWorkloadDetail", () => {
// Aggregates a task list into running/queued counts before classifying.
// Terminal statuses (completed / failed / cancelled) are silently
// ignored — workload is "what's on the plate right now", not history.
it("returns idle when no tasks at all", () => {
const r = deriveLastTaskState([]);
expect(r.state).toBe("idle");
const r = deriveWorkloadDetail([]);
expect(r.workload).toBe("idle");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(0);
});
it("returns running when at least one task is running", () => {
const r = deriveLastTaskState([makeTask({ status: "running" })]);
expect(r.state).toBe("running");
it("returns working when at least one task is running", () => {
const r = deriveWorkloadDetail([makeTask({ status: "running" })]);
expect(r.workload).toBe("working");
expect(r.runningCount).toBe(1);
expect(r.queuedCount).toBe(0);
});
it("returns running when only queued / dispatched tasks exist (no running yet)", () => {
const r = deriveLastTaskState([
it("returns queued when only queued / dispatched tasks exist (no running)", () => {
// The "stuck on offline runtime" scenario in isolation: runningCount=0,
// queuedCount>0 surfaces as `queued` so the UI can honestly say
// "Queued · N" instead of misleading "Running 0/3 +Nq".
const r = deriveWorkloadDetail([
makeTask({ status: "queued" }),
makeTask({ id: "t2", status: "dispatched" }),
]);
expect(r.state).toBe("running");
expect(r.workload).toBe("queued");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(2);
});
it("returns running even when an older terminal exists (active wins over historical)", () => {
const r = deriveLastTaskState([
makeTask({
id: "old-failed",
status: "failed",
completed_at: "2026-04-27T10:00:00Z",
}),
makeTask({ id: "new-running", status: "running" }),
it("returns working when running coexists with queued (overflow)", () => {
// Capacity-saturated agent: still running, but with a queue building.
// The chip says "Working" with the queue expressed as a `+Nq` badge.
const r = deriveWorkloadDetail([
makeTask({ id: "t1", status: "running" }),
makeTask({ id: "t2", status: "queued" }),
makeTask({ id: "t3", status: "queued" }),
]);
expect(r.state).toBe("running");
expect(r.workload).toBe("working");
expect(r.runningCount).toBe(1);
expect(r.queuedCount).toBe(2);
});
it("returns the latest terminal state when no tasks are active (latest = failed)", () => {
const r = deriveLastTaskState([
it("ignores terminal statuses entirely (no historical state in workload)", () => {
// Failed / completed / cancelled tasks contribute no count and don't
// change the verdict — Recent Work + Inbox handle history, not workload.
const r = deriveWorkloadDetail([
makeTask({
id: "old",
id: "t-failed",
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
makeTask({
id: "t-completed",
status: "completed",
completed_at: "2026-04-27T10:00:00Z",
completed_at: "2026-04-27T11:00:00Z",
}),
makeTask({
id: "new",
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
]);
expect(r.state).toBe("failed");
expect(r.lastTaskCompletedAt).toBe("2026-04-27T11:30:00Z");
});
it("returns the latest terminal state when no tasks are active (latest = completed)", () => {
const r = deriveLastTaskState([
makeTask({
id: "old",
status: "failed",
completed_at: "2026-04-27T10:00:00Z",
}),
makeTask({
id: "new",
status: "completed",
completed_at: "2026-04-27T11:30:00Z",
}),
]);
expect(r.state).toBe("completed");
});
it("surfaces failure_reason on a failed latest terminal", () => {
const reason: TaskFailureReason = "runtime_offline";
const r = deriveLastTaskState([
makeTask({
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
failure_reason: reason,
}),
]);
expect(r.state).toBe("failed");
expect(r.failureReason).toBe(reason);
});
it("leaves failureReason undefined when the failed terminal has empty failure_reason", () => {
const r = deriveLastTaskState([
makeTask({
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
failure_reason: "",
}),
]);
expect(r.state).toBe("failed");
expect(r.failureReason).toBeUndefined();
});
it("returns cancelled when the latest terminal is cancelled", () => {
// Under the new model cancelled is a real state — the dot is
// availability-driven so honestly surfacing it doesn't lie.
const r = deriveLastTaskState([
makeTask({
id: "t-cancelled",
status: "cancelled",
completed_at: "2026-04-27T11:30:00Z",
completed_at: "2026-04-27T10:30:00Z",
}),
]);
expect(r.state).toBe("cancelled");
expect(r.workload).toBe("idle");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(0);
});
it("ignores terminals without completed_at (treated as not-terminal)", () => {
// Defensive: a malformed row (no completed_at) shouldn't derail the
// latest-terminal scan. With nothing else in flight, idle.
const r = deriveLastTaskState([makeTask({ status: "failed", completed_at: null })]);
expect(r.state).toBe("idle");
it("classifies running over queued when both present, regardless of order", () => {
const r = deriveWorkloadDetail([
makeTask({ id: "t1", status: "queued" }),
makeTask({ id: "t2", status: "running" }),
]);
expect(r.workload).toBe("working");
});
});
describe("deriveAgentPresenceDetail", () => {
// Composition: the two dimensions are derived independently and the
// detail object exposes both. No cross-axis override (the old "unstable
// overrides failed" rule is gone — they coexist now).
// detail object exposes both. No cross-axis override — workload never
// colours the dot, availability never overrides workload.
it("composes online + running for the common busy case", () => {
it("composes online + working for the common busy case", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime(),
@@ -252,53 +235,53 @@ describe("deriveAgentPresenceDetail", () => {
now: NOW,
});
expect(detail.availability).toBe("online");
expect(detail.lastTask).toBe("running");
expect(detail.workload).toBe("working");
expect(detail.runningCount).toBe(1);
expect(detail.queuedCount).toBe(1);
expect(detail.capacity).toBe(6);
});
it("composes online + failed — agent is reachable but last task failed (no longer sticky red dot)", () => {
// The whole motivation for the split: this combination was previously
// collapsed to a single red "failed" state, hiding the fact that the
// runtime is fine. Now the two dimensions are visible separately.
it("composes offline + queued — the canonical 'stuck' case (was previously misleading 'running 0/N')", () => {
// The motivation for the redesign: runtime offline + queued tasks
// used to surface as `running` with `0/3 +2q` counts (literally false).
// Workload now returns `queued` honestly, paired with offline
// availability — UI reads "Offline · Queued · 2".
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime(),
runtime: makeRuntime({
status: "offline",
last_seen_at: "2026-04-27T11:50:00Z",
}),
tasks: [
makeTask({
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
failure_reason: "agent_error",
}),
makeTask({ status: "queued" }),
makeTask({ id: "t2", status: "queued" }),
],
now: NOW,
});
expect(detail.availability).toBe("online");
expect(detail.lastTask).toBe("failed");
expect(detail.failureReason).toBe("agent_error");
expect(detail.lastTaskCompletedAt).toBe("2026-04-27T11:30:00Z");
expect(detail.availability).toBe("offline");
expect(detail.workload).toBe("queued");
expect(detail.runningCount).toBe(0);
expect(detail.queuedCount).toBe(2);
});
it("composes unstable + running — runtime hiccup with queued tasks still in flight", () => {
// Previously "unstable" overrode "working"; now both signals are
// surfaced. The UI shows amber dot AND running chip — user sees both
// "connection issue" and "queue is paused".
it("composes unstable + working — runtime hiccup with tasks still in flight", () => {
// Recently-lost runtime, but a task is still recorded as running.
// Both signals surface independently — amber dot AND working chip —
// so the user sees "connection wobbling" alongside "agent is busy".
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime({
status: "offline",
last_seen_at: "2026-04-27T11:59:00Z",
}),
tasks: [makeTask({ status: "queued" })],
tasks: [makeTask({ status: "running" })],
now: NOW,
});
expect(detail.availability).toBe("unstable");
expect(detail.lastTask).toBe("running");
expect(detail.queuedCount).toBe(1);
expect(detail.workload).toBe("working");
});
it("composes offline + idle for a brand-new agent on a dead runtime", () => {
it("composes offline + idle for an unreachable agent with no tasks pending", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime({
@@ -309,34 +292,34 @@ describe("deriveAgentPresenceDetail", () => {
now: NOW,
});
expect(detail.availability).toBe("offline");
expect(detail.lastTask).toBe("idle");
expect(detail.workload).toBe("idle");
});
it("handles a missing runtime by reporting offline + the task-driven last state", () => {
it("handles a missing runtime by reporting offline + the task-driven workload", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: null,
tasks: [makeTask({ status: "running" })],
now: NOW,
});
expect(detail.availability).toBe("offline");
expect(detail.workload).toBe("working");
});
it("returns idle workload when only terminal tasks are present (history doesn't bleed in)", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime(),
tasks: [
makeTask({
status: "completed",
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
],
now: NOW,
});
expect(detail.availability).toBe("offline");
expect(detail.lastTask).toBe("completed");
});
it("leaves failureReason / lastTaskCompletedAt undefined when not relevant", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime(),
tasks: [makeTask({ status: "running" })],
now: NOW,
});
expect(detail.failureReason).toBeUndefined();
expect(detail.lastTaskCompletedAt).toBeUndefined();
expect(detail.availability).toBe("online");
expect(detail.workload).toBe("idle");
});
it("mirrors agent.max_concurrent_tasks into capacity", () => {
@@ -359,21 +342,16 @@ describe("buildPresenceMap", () => {
runtimes: [makeRuntime()],
snapshot: [
makeTask({ id: "t1", agent_id: "a", status: "running" }),
makeTask({
id: "t2",
agent_id: "b",
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
makeTask({ id: "t2", agent_id: "b", status: "queued" }),
],
now: NOW,
});
const a = map.get("a");
const b = map.get("b");
expect(a?.availability).toBe("online");
expect(a?.lastTask).toBe("running");
expect(a?.workload).toBe("working");
expect(b?.availability).toBe("online");
expect(b?.lastTask).toBe("failed");
expect(b?.workload).toBe("queued");
});
it("returns offline availability for agents whose runtime_id has no matching runtime", () => {
@@ -386,8 +364,8 @@ describe("buildPresenceMap", () => {
});
const o = map.get("orphan");
expect(o?.availability).toBe("offline");
// Task dimension still resolves independently — running task counts.
expect(o?.lastTask).toBe("running");
// Workload still resolves independently — running task counts.
expect(o?.workload).toBe("working");
});
it("threads the same `now` so every agent on a shared runtime gets the same availability", () => {
@@ -406,19 +384,35 @@ describe("buildPresenceMap", () => {
],
snapshot: [
makeTask({ id: "t1", agent_id: "a", status: "queued" }),
makeTask({
id: "t2",
agent_id: "b",
status: "failed",
completed_at: "2026-04-27T11:00:00Z",
}),
makeTask({ id: "t2", agent_id: "b", status: "running" }),
],
now: NOW,
});
expect(map.get("a")?.availability).toBe("unstable");
expect(map.get("b")?.availability).toBe("unstable");
// Last-task remains independent: a is running (queued), b is failed.
expect(map.get("a")?.lastTask).toBe("running");
expect(map.get("b")?.lastTask).toBe("failed");
// Workload remains independent: a is queued (waiting), b is working.
expect(map.get("a")?.workload).toBe("queued");
expect(map.get("b")?.workload).toBe("working");
});
it("ignores terminal tasks in the snapshot when building per-agent workload", () => {
// Snapshot intentionally still includes each agent's most recent
// terminal task (back-end SQL didn't change); the front-end now
// filters them out at the workload-derivation step.
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
const map = buildPresenceMap({
agents: [agentA],
runtimes: [makeRuntime()],
snapshot: [
makeTask({
id: "t-terminal",
agent_id: "a",
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
],
now: NOW,
});
expect(map.get("a")?.workload).toBe("idle");
});
});

View File

@@ -4,18 +4,19 @@
// dimensions:
//
// 1. AgentAvailability — derived from runtime reachability only.
// 2. LastTaskState — derived from the task snapshot only.
// 2. Workload — derived from the task counts only.
//
// They are computed independently and assembled into AgentPresenceDetail.
// No cross-dimension override logic — that was the source of the previous
// model's "sticky red dot" confusion.
// Workload is strictly "what's on the plate right now" — no historical
// terminal state. Past failures / completions live on the detail page
// (Recent Work, failure_reason) and Inbox.
import { deriveRuntimeHealth } from "../runtimes/derive-health";
import type { Agent, AgentRuntime, AgentTask, TaskFailureReason } from "../types";
import type { Agent, AgentRuntime, AgentTask } from "../types";
import type {
AgentAvailability,
AgentPresenceDetail,
LastTaskState,
Workload,
} from "./types";
// AgentAvailability mirrors RuntimeHealth's reachability buckets but folds
@@ -33,76 +34,44 @@ export function deriveAgentAvailability(
return "offline"; // offline | about_to_gc collapse here
}
interface LastTaskResult {
state: LastTaskState;
// Atomic workload derivation: pure 3-way classification of running/queued
// counts. Exported so Runtime-level views (which already aggregate counts
// per-runtime in their own indices) can plug into the same vocabulary
// without re-deriving from raw task arrays.
export function deriveWorkload(counts: {
runningCount: number;
queuedCount: number;
failureReason?: TaskFailureReason;
lastTaskCompletedAt?: string;
}): Workload {
if (counts.runningCount > 0) return "working";
if (counts.queuedCount > 0) return "queued";
return "idle";
}
// Single pass: count actives + track latest terminal by completed_at. A
// running OR queued task means the agent is currently busy ("running"
// state); only when nothing is in flight do we fall through to the latest
// terminal (which can be completed / failed / cancelled). With no terminal
// history at all, we report `idle`.
//
// Cancelled is no longer filtered out — under the new model the dot is
// availability-driven so honestly surfacing "cancelled" doesn't risk
// lying about whether the agent works. The previous "exclude cancelled
// to keep red sticky" hack is gone.
export function deriveLastTaskState(tasks: readonly AgentTask[]): LastTaskResult {
interface WorkloadDetail {
workload: Workload;
runningCount: number;
queuedCount: number;
}
// Aggregates a task list into running/queued counts, then classifies via
// deriveWorkload. Caller pre-filters to the relevant scope (per-agent or
// per-runtime) — we don't filter again here.
export function deriveWorkloadDetail(tasks: readonly AgentTask[]): WorkloadDetail {
let runningCount = 0;
let queuedCount = 0;
let latestTerminal: AgentTask | null = null;
let latestTerminalAt = -Infinity;
for (const t of tasks) {
if (t.status === "running") {
runningCount += 1;
} else if (t.status === "queued" || t.status === "dispatched") {
queuedCount += 1;
} else if (t.completed_at) {
const ts = new Date(t.completed_at).getTime();
if (!Number.isNaN(ts) && ts > latestTerminalAt) {
latestTerminalAt = ts;
latestTerminal = t;
}
}
// Terminal statuses (completed / failed / cancelled) intentionally
// ignored — workload is "what's on the plate right now", not history.
}
if (runningCount + queuedCount > 0) {
return { state: "running", runningCount, queuedCount };
}
if (!latestTerminal) {
return { state: "idle", runningCount: 0, queuedCount: 0 };
}
const completedAt = latestTerminal.completed_at ?? undefined;
if (latestTerminal.status === "failed") {
return {
state: "failed",
runningCount: 0,
queuedCount: 0,
failureReason: latestTerminal.failure_reason || undefined,
lastTaskCompletedAt: completedAt,
};
}
if (latestTerminal.status === "cancelled") {
return {
state: "cancelled",
runningCount: 0,
queuedCount: 0,
lastTaskCompletedAt: completedAt,
};
}
// completed
return {
state: "completed",
runningCount: 0,
queuedCount: 0,
lastTaskCompletedAt: completedAt,
workload: deriveWorkload({ runningCount, queuedCount }),
runningCount,
queuedCount,
};
}
@@ -119,16 +88,14 @@ interface DerivePresenceInput {
export function deriveAgentPresenceDetail(input: DerivePresenceInput): AgentPresenceDetail {
const availability = deriveAgentAvailability(input.runtime, input.now);
const last = deriveLastTaskState(input.tasks);
const detail = deriveWorkloadDetail(input.tasks);
return {
availability,
lastTask: last.state,
runningCount: last.runningCount,
queuedCount: last.queuedCount,
workload: detail.workload,
runningCount: detail.runningCount,
queuedCount: detail.queuedCount,
capacity: input.agent.max_concurrent_tasks,
failureReason: last.failureReason,
lastTaskCompletedAt: last.lastTaskCompletedAt,
};
}
@@ -138,9 +105,10 @@ export function deriveAgentPresenceDetail(input: DerivePresenceInput): AgentPres
export function buildPresenceMap(args: {
agents: readonly Agent[];
runtimes: readonly AgentRuntime[];
// The workspace agent task snapshot: every active task + each agent's
// The workspace agent task snapshot: every active task plus each agent's
// most recent terminal task. Comes straight from getAgentTaskSnapshot()
// — no pre-filtering needed.
// — no pre-filtering needed. Terminal rows are silently ignored by
// deriveWorkloadDetail (workload is current-state only).
snapshot: readonly AgentTask[];
now: number;
}): Map<string, AgentPresenceDetail> {

View File

@@ -4,3 +4,6 @@ export * from "./queries";
export * from "./use-agent-presence";
export * from "./use-agent-activity";
export * from "./use-workspace-presence-prefetch";
export * from "./constants";
export * from "./visibility-label";
export * from "./use-workspace-agent-availability";

View File

@@ -3,23 +3,25 @@
// front-end from raw server data (agent + runtime + recent tasks); the
// back-end never knows about these enums.
//
// Two orthogonal dimensions, derived independently:
// Two orthogonal dimensions, derived independently and answering only
// "what's true right now?" — historical / error context lives on the
// agent detail page (Recent Work, failure_reason) and Inbox, not in the
// list-level summary state:
//
// 1. AgentAvailability — "Can this agent take work right now?"
// Depends only on runtime reachability. The dot colour everywhere in
// the app reflects this single dimension; never sticky-red because of
// a past task outcome.
//
// 2. LastTaskState — "What was the last thing this agent did?"
// Depends only on the workspace task snapshot. Surfaced as text + icon
// on focused surfaces (hover card, agent detail, agent list, runtime
// detail). Never colours the dot.
//
// The previous single 5-state union conflated the two: a runtime-healthy
// agent whose last task failed would show a red dot indistinguishable from
// a daemon-dead agent. Splitting them lets each signal be unambiguous.
import type { TaskFailureReason } from "../types";
// 2. Workload — "What is on this agent's plate right now?"
// Depends only on the workspace task snapshot. Three states, each
// pointing at a clear user action:
// working → tasks running, normal
// queued → tasks queued but nothing running (= stuck if availability
// is offline/unstable; momentary if online)
// idle → nothing to do
// No `failed` / `completed` / `cancelled` states — those are historical,
// surfaced via Recent Work + Inbox.
// Runtime-reachability dimension. `unstable` is the transient amber state
// during the runtime sweeper's grace window (offline < 5 min); it decays
@@ -30,40 +32,31 @@ export type AgentAvailability =
| "unstable" // 🟡 runtime recently_lost (< 5 min) — transient
| "offline"; // ⚫ runtime long offline / missing / never registered
// Last-task dimension. Active and terminal merged into one enum because
// only one applies at a time: while there's any in-flight task the state
// is `running`; once everything terminates we read the latest outcome;
// with no history at all, `idle`.
// Current task load on this agent. Three states — never historical,
// never an error predictor (Inbox + Recent Work handle that):
//
// `running` covers both `running` and `queued/dispatched` tasks because
// from the user's perspective "agent is busy" is the same answer; the
// running/queued counts on the detail object preserve the breakdown.
// working → runningCount > 0. The runningCount/queuedCount on the detail
// object preserve the breakdown for display.
// queued → no running task but ≥1 queued/dispatched. Most often means
// the runtime is offline and tasks are stuck waiting; a brief
// flash on online runtimes between dispatch and run is a
// harmless race.
// idle → nothing on the plate.
//
// `cancelled` is included as a discrete state (vs. folding into the
// previous filter that excluded cancelled from terminal selection). With
// the dot no longer colour-coded by task state, surfacing "cancelled"
// honestly is fine — it doesn't risk lying about availability.
export type LastTaskState =
| "running" // ≥1 task running or queued right now
| "completed" // latest terminal: completed
| "failed" // latest terminal: failed
| "cancelled" // latest terminal: cancelled
| "idle"; // no active task and no terminal history
// Pair with availability for the full picture: `online + working` is
// normal; `offline + queued` is the "stuck" state we explicitly surface;
// `offline + idle` is "agent unavailable, nothing waiting" — both honest.
export type Workload =
| "working" // ≥1 task currently running
| "queued" // nothing running, but ≥1 queued/dispatched
| "idle"; // nothing on the plate
export interface AgentPresenceDetail {
availability: AgentAvailability;
lastTask: LastTaskState;
workload: Workload;
runningCount: number;
queuedCount: number;
// Mirrors agent.max_concurrent_tasks — pulled into the detail so the UI
// can render `running / capacity` ratios without re-fetching the agent.
capacity: number;
// Set only when lastTask === "failed". The label lookup happens at the
// UI layer; deriving exposes the raw classifier so the UI can choose copy.
failureReason?: TaskFailureReason;
// Wall-clock timestamp of the latest terminal task. Set whenever
// lastTask is one of completed / failed / cancelled. Used to render
// "Last run: failed · 12 min ago" copy. Undefined for `running` (no
// terminal yet) and `idle` (no history).
lastTaskCompletedAt?: string;
}

View File

@@ -110,7 +110,7 @@ export function useWorkspacePresenceMap(wsId: string | undefined): {
// skeleton spinning forever.
const MISSING_AGENT_DETAIL: AgentPresenceDetail = {
availability: "offline",
lastTask: "idle",
workload: "idle",
runningCount: 0,
queuedCount: 0,
capacity: 0,

View File

@@ -0,0 +1,60 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "../hooks";
import { useAuthStore } from "../auth";
import { agentListOptions, memberListOptions } from "../workspace/queries";
import { canAssignAgentToIssue } from "../permissions";
/**
* Three-state availability for "does the current user have any agent
* they can chat with in this workspace?".
*
* Why three states (not a boolean): the answer to "is there an agent?"
* lives on the server. Until the agent-list query resolves, the answer
* is genuinely *unknown*. Callers must distinguish "loading" from
* "confirmed empty" — collapsing them to a boolean causes UIs to flash
* disabled/empty states for the first few hundred ms after mount, even
* when the workspace actually has agents.
*
* "loading" — agent or member list still in flight (be neutral in UI)
* "none" — both queries resolved, user has zero assignable agents
* "available" — at least one agent passes archive + visibility filters
*/
export type WorkspaceAgentAvailability = "loading" | "none" | "available";
/**
* Mirrors the per-agent visibility/archived filter used by AssigneePicker
* and the chat agent dropdown, so the three pickers can never disagree on
* "is this agent reachable?".
*
* Members are queried because `canAssignAgentToIssue` reads the caller's
* role to decide visibility for `private` agents — without member data,
* a freshly-loaded agent list could still produce wrong answers.
*/
export function useWorkspaceAgentAvailability(): WorkspaceAgentAvailability {
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id);
const { data: agents, isFetched: agentsFetched } = useQuery(
agentListOptions(wsId),
);
const { data: members, isFetched: membersFetched } = useQuery(
memberListOptions(wsId),
);
if (!agentsFetched || !membersFetched) return "loading";
const rawRole = members?.find((m) => m.user_id === userId)?.role;
const role =
rawRole === "owner" || rawRole === "admin" || rawRole === "member"
? rawRole
: null;
const hasVisibleAgent = (agents ?? []).some(
(a) =>
!a.archived_at &&
canAssignAgentToIssue(a, { userId: userId ?? null, role }).allowed,
);
return hasVisibleAgent ? "available" : "none";
}

View File

@@ -0,0 +1,31 @@
import type { AgentVisibility } from "../types";
/**
* Display labels for agent visibility. The DB stores `private` as the value
* but the UI surface name is "Personal" — better matches what the field
* actually means now that workspace admins can also assign private agents.
*/
export const VISIBILITY_LABEL: Record<AgentVisibility, string> = {
workspace: "Workspace",
private: "Personal",
};
/**
* Honest descriptions for assignability. The previous "Only you can assign"
* text was a lie — workspace owners and admins can assign private agents too
* (server `issue.go:1471-1490`).
*/
export const VISIBILITY_DESCRIPTION: Record<AgentVisibility, string> = {
workspace: "All members can assign",
private: "Only you and workspace admins can assign",
};
/** Tooltip suitable for read-only badges on hover/list rows. */
export const VISIBILITY_TOOLTIP: Record<AgentVisibility, string> = {
workspace: "Workspace — all members can assign",
private: "Personal — only you and workspace admins can assign",
};
export function visibilityLabel(v: AgentVisibility): string {
return VISIBILITY_LABEL[v];
}

View File

@@ -55,6 +55,9 @@ import type {
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
CreateProjectResourceRequest,
ListProjectResourcesResponse,
Label,
CreateLabelRequest,
UpdateLabelRequest,
@@ -75,6 +78,8 @@ import type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
NotificationPreferenceResponse,
NotificationPreferences,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -783,6 +788,18 @@ export class ApiClient {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
}
// Notification preferences
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
return this.fetch("/api/notification-preferences");
}
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
return this.fetch("/api/notification-preferences", {
method: "PUT",
body: JSON.stringify({ preferences }),
});
}
// App Config
async getConfig(): Promise<{
cdn_domain: string;
@@ -1060,6 +1077,32 @@ export class ApiClient {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
// Project resources
async listProjectResources(
projectId: string,
): Promise<ListProjectResourcesResponse> {
return this.fetch(`/api/projects/${projectId}/resources`);
}
async createProjectResource(
projectId: string,
data: CreateProjectResourceRequest,
): Promise<ProjectResource> {
return this.fetch(`/api/projects/${projectId}/resources`, {
method: "POST",
body: JSON.stringify(data),
});
}
async deleteProjectResource(
projectId: string,
resourceId: string,
): Promise<void> {
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
method: "DELETE",
});
}
// Labels
async listLabels(): Promise<ListLabelsResponse> {
return this.fetch(`/api/labels`);

View File

@@ -16,6 +16,14 @@ const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
/** Focus mode is a personal preference — global across workspaces/sessions. */
const FOCUS_MODE_KEY = "multica:chat:focusMode";
/**
* Open/closed preference, persisted globally (not per-workspace) — most users
* have one habitual chat-panel preference across workspaces. Missing key =
* new user (or cleared storage); default to OPEN so the chat is discoverable.
* Once the user toggles even once, their explicit choice is respected on
* every subsequent reload.
*/
const OPEN_KEY = "multica:chat:isOpen";
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
const raw = storage.getItem(key);
@@ -43,7 +51,7 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_W = 380;
export const CHAT_DEFAULT_H = 600;
/**
@@ -118,8 +126,14 @@ export function createChatStore(options: ChatStoreOptions) {
return slug ? `${base}:${slug}` : base;
};
// Resolve initial isOpen from storage. The three-state read (null /
// "true" / "false") is what enables the "new user → open" default while
// still honouring an explicit "I closed it" choice on every reload.
const storedOpen = storage.getItem(OPEN_KEY);
const initialIsOpen = storedOpen === null ? true : storedOpen === "true";
const store = create<ChatState>((set, get) => ({
isOpen: false,
isOpen: initialIsOpen,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
@@ -130,11 +144,13 @@ export function createChatStore(options: ChatStoreOptions) {
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
setOpen: (open) => {
logger.debug("setOpen", { from: get().isOpen, to: open });
storage.setItem(OPEN_KEY, String(open));
set({ isOpen: open });
},
toggle: () => {
const next = !get().isOpen;
logger.debug("toggle", { to: next });
storage.setItem(OPEN_KEY, String(next));
set({ isOpen: next });
},
setActiveSession: (id) => {

View File

@@ -0,0 +1,41 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
interface FeedbackDraft {
message: string;
}
const EMPTY_DRAFT: FeedbackDraft = {
message: "",
};
interface FeedbackDraftStore {
draft: FeedbackDraft;
setDraft: (patch: Partial<FeedbackDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useFeedbackDraftStore = create<FeedbackDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () =>
set({ draft: { ...EMPTY_DRAFT } }),
hasDraft: () => {
const { draft } = get();
return !!draft.message;
},
}),
{
name: "multica_feedback_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useFeedbackDraftStore.persist.rehydrate());

View File

@@ -1 +1,2 @@
export * from "./mutations";
export { useFeedbackDraftStore } from "./draft-store";

View File

@@ -22,6 +22,12 @@ export const issueKeys = {
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
usage: (issueId: string) => ["issues", "usage", issueId] as const,
/** Per-issue task list (issue-detail Execution log section). */
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
/** Prefix-match key for invalidating tasks across all issues — used by
* the global WS task: prefix path so any task lifecycle event refreshes
* every per-issue list, regardless of which issue is currently mounted. */
tasksAll: () => ["issues", "tasks"] as const,
};
export type MyIssuesFilter = Pick<

View File

@@ -15,6 +15,8 @@ import { defaultStorage } from "../../platform/storage";
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
keepOpen: boolean;
setKeepOpen: (v: boolean) => void;
}
export const useQuickCreateStore = create<QuickCreateState>()(
@@ -22,6 +24,8 @@ export const useQuickCreateStore = create<QuickCreateState>()(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
keepOpen: false,
setKeepOpen: (v) => set({ keepOpen: v }),
}),
{
name: "multica_quick_create",

View File

@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./mutations";

View File

@@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { notificationPreferenceKeys } from "./queries";
import type { NotificationPreferences, NotificationPreferenceResponse } from "../types";
export function useUpdateNotificationPreferences() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (preferences: NotificationPreferences) =>
api.updateNotificationPreferences(preferences),
onMutate: async (preferences) => {
await qc.cancelQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
const prev = qc.getQueryData<NotificationPreferenceResponse>(
notificationPreferenceKeys.all(wsId),
);
qc.setQueryData<NotificationPreferenceResponse>(
notificationPreferenceKeys.all(wsId),
(old) => old ? { ...old, preferences } : { workspace_id: wsId, preferences },
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) {
qc.setQueryData(notificationPreferenceKeys.all(wsId), ctx.prev);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
},
});
}

View File

@@ -0,0 +1,13 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const notificationPreferenceKeys = {
all: (wsId: string) => ["notification-preferences", wsId] as const,
};
export function notificationPreferenceOptions(wsId: string) {
return queryOptions({
queryKey: notificationPreferenceKeys.all(wsId),
queryFn: () => api.getNotificationPreferences(),
});
}

View File

@@ -16,7 +16,8 @@ export type OnboardingCompletionPath =
| "full" // Reached Step 5 (first_issue) with a runtime connected
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
| "skip_existing"; // "I've done this before" from Welcome
| "skip_existing" // "I've done this before" from Welcome
| "invite_accept"; // Accepted at least one invite from /invitations
export type TeamSize = "solo" | "team" | "other";

View File

@@ -35,6 +35,9 @@
"./inbox/queries": "./inbox/queries.ts",
"./inbox/mutations": "./inbox/mutations.ts",
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
"./chat": "./chat/index.ts",
"./chat/queries": "./chat/queries.ts",
"./chat/mutations": "./chat/mutations.ts",
@@ -46,6 +49,8 @@
"./agents/queries": "./agents/queries.ts",
"./agents/derive-presence": "./agents/derive-presence.ts",
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
"./agents/visibility-label": "./agents/visibility-label.ts",
"./permissions": "./permissions/index.ts",
"./projects": "./projects/index.ts",
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",

View File

@@ -43,6 +43,7 @@ export const paths = {
login: () => "/login",
newWorkspace: () => "/workspaces/new",
invite: (id: string) => `/invite/${encode(id)}`,
invitations: () => "/invitations",
onboarding: () => "/onboarding",
authCallback: () => "/auth/callback",
root: () => "/",
@@ -54,7 +55,7 @@ export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
// A path is global if it equals or begins with any of these.
// Note: `/workspaces/` (trailing slash) is the prefix — `workspaces` is reserved,
// so any path starting with `/workspaces/...` is system-owned, not user-owned.
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/onboarding", "/auth/", "/logout", "/signup"];
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/invitations", "/onboarding", "/auth/", "/logout", "/signup"];
export function isGlobalPath(path: string): boolean {
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));

View File

@@ -20,6 +20,7 @@ export const RESERVED_SLUGS = new Set([
"oauth",
"callback",
"invite",
"invitations",
"verify",
"reset",
"password",

View File

@@ -19,14 +19,16 @@ function makeWs(slug: string): Workspace {
}
describe("resolvePostAuthDestination", () => {
it("not onboarded → /onboarding regardless of workspaces", () => {
it("!onboarded → /onboarding regardless of workspace count", () => {
// Un-onboarded users are routed back to the onboarding flow. The
// "un-onboarded but in workspace" state is now physically impossible
// (backend invariant + migration 065 backfill), but the resolver still
// does the right thing if it ever appears: send the user to onboarding
// rather than dropping them into a workspace with `onboarded_at` null.
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
paths.onboarding(),
);
expect(
resolvePostAuthDestination([makeWs("acme"), makeWs("beta")], false),
).toBe(paths.onboarding());
});
it("onboarded + has workspace → /<first.slug>/issues", () => {

View File

@@ -7,6 +7,18 @@ import { paths } from "./paths";
* !hasOnboarded → /onboarding
* hasOnboarded && has workspace → /<first.slug>/issues
* hasOnboarded && zero workspaces → /workspaces/new
*
* `onboarded_at` is the single source of truth for whether the user has
* passed first-contact. Backend transactions (CreateWorkspace,
* AcceptInvitation) atomically set this field whenever a user joins a
* `member` row, so "has workspace but !onboarded" is now a
* physically impossible state — see migration 065 for the existing-data
* backfill that closed the door retroactively.
*
* Callers that need invitation-aware routing (callback / login) handle the
* "un-onboarded with pending invites" branch themselves before calling
* this resolver — this resolver only deals with the post-invite-check
* destination.
*/
export function resolvePostAuthDestination(
workspaces: Workspace[],
@@ -16,7 +28,10 @@ export function resolvePostAuthDestination(
return paths.onboarding();
}
const first = workspaces[0];
return first ? paths.workspace(first.slug).issues() : paths.newWorkspace();
if (first) {
return paths.workspace(first.slug).issues();
}
return paths.newWorkspace();
}
/**

View File

@@ -0,0 +1,20 @@
/**
* Public API for the permissions module.
*
* Exports only what the views currently consume. The full pure-rule set lives
* in `./rules` and is available to tests and future surfaces directly. Adding
* a new rule to the public API should follow the same minimum-surface pattern
* — only export when there's a caller.
*/
export type {
Decision,
DecisionReason,
PermissionContext,
} from "./types";
export { canAssignAgentToIssue, canEditAgent } from "./rules";
export {
useAgentPermissions,
useSkillPermissions,
} from "./use-resource-permissions";

View File

@@ -0,0 +1,329 @@
import { describe, expect, it } from "vitest";
import type { Agent, Comment, Member, RuntimeDevice, Skill } from "../types";
import {
canAssignAgentToIssue,
canChangeMemberRole,
canDeleteComment,
canDeleteRuntime,
canDeleteSkill,
canDeleteWorkspace,
canEditAgent,
canEditComment,
canEditSkill,
canManageMembers,
canUpdateWorkspaceSettings,
} from "./rules";
const ALICE = "user-alice";
const BOB = "user-bob";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agt_1",
workspace_id: "ws_1",
runtime_id: "rt_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,
model: "default",
owner_id: ALICE,
skills: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
function makeSkill(createdBy: string | null): Skill {
return {
id: "skl_1",
workspace_id: "ws_1",
name: "skill",
description: "",
content: "",
config: {},
files: [],
created_by: createdBy,
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
};
}
function makeComment(overrides: Partial<Comment> = {}): Comment {
return {
id: "cmt_1",
issue_id: "iss_1",
author_type: "member",
author_id: ALICE,
content: "hi",
type: "comment",
parent_id: null,
reactions: [],
attachments: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
...overrides,
};
}
function makeRuntime(ownerId: string | null): RuntimeDevice {
return {
id: "rt_1",
workspace_id: "ws_1",
daemon_id: null,
name: "runtime",
runtime_mode: "local",
provider: "anthropic",
launch_header: "",
status: "online",
device_info: "",
metadata: {},
owner_id: ownerId,
last_seen_at: null,
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
};
}
describe("canEditAgent", () => {
const agent = makeAgent({ owner_id: ALICE });
it("allows the owner", () => {
expect(canEditAgent(agent, { userId: ALICE, role: "member" }).allowed).toBe(
true,
);
});
it("allows workspace owner", () => {
expect(canEditAgent(agent, { userId: BOB, role: "owner" }).allowed).toBe(
true,
);
});
it("allows workspace admin", () => {
expect(canEditAgent(agent, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("denies non-owner member", () => {
const d = canEditAgent(agent, { userId: BOB, role: "member" });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_resource_owner");
});
it("denies when userId is null", () => {
const d = canEditAgent(agent, { userId: null, role: null });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_authenticated");
});
it("denies when agent owner_id is null and user is plain member", () => {
const orphan = makeAgent({ owner_id: null });
expect(
canEditAgent(orphan, { userId: ALICE, role: "member" }).allowed,
).toBe(false);
});
it("admin can still edit an orphan (owner_id null) agent", () => {
const orphan = makeAgent({ owner_id: null });
expect(canEditAgent(orphan, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
});
describe("canAssignAgentToIssue", () => {
it("allows any member to assign workspace-visibility agents", () => {
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
expect(
canAssignAgentToIssue(a, { userId: BOB, role: "member" }).allowed,
).toBe(true);
});
it("denies non-members from assigning workspace agents", () => {
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
const d = canAssignAgentToIssue(a, { userId: BOB, role: null });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_member");
});
it("allows the owner to assign their private agent", () => {
const a = makeAgent({ visibility: "private", owner_id: ALICE });
expect(
canAssignAgentToIssue(a, { userId: ALICE, role: "member" }).allowed,
).toBe(true);
});
it("allows workspace admin to assign someone else's private agent", () => {
const a = makeAgent({ visibility: "private", owner_id: ALICE });
expect(
canAssignAgentToIssue(a, { userId: BOB, role: "admin" }).allowed,
).toBe(true);
});
it("denies a plain member from assigning someone else's private agent", () => {
const a = makeAgent({ visibility: "private", owner_id: ALICE });
const d = canAssignAgentToIssue(a, { userId: BOB, role: "member" });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("private_visibility");
});
it("denies logged-out users", () => {
const a = makeAgent({ visibility: "workspace" });
const d = canAssignAgentToIssue(a, { userId: null, role: null });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_authenticated");
});
});
describe("canEditSkill / canDeleteSkill", () => {
const skill = makeSkill(ALICE);
it("allows admins", () => {
expect(canEditSkill(skill, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("allows the creator", () => {
expect(canEditSkill(skill, { userId: ALICE, role: "member" }).allowed)
.toBe(true);
});
it("denies non-creator member", () => {
expect(canEditSkill(skill, { userId: BOB, role: "member" }).allowed)
.toBe(false);
});
it("denies when created_by is null and user is plain member", () => {
expect(
canEditSkill(makeSkill(null), { userId: ALICE, role: "member" }).allowed,
).toBe(false);
});
it("canDeleteSkill mirrors canEditSkill", () => {
expect(canDeleteSkill(skill, { userId: ALICE, role: "member" }).allowed)
.toBe(true);
expect(canDeleteSkill(skill, { userId: BOB, role: "member" }).allowed)
.toBe(false);
});
});
describe("canEditComment / canDeleteComment", () => {
it("allows the author to edit their own comment", () => {
const c = makeComment({ author_id: ALICE });
expect(canEditComment(c, { userId: ALICE, role: "member" }).allowed).toBe(
true,
);
});
it("allows workspace admin to edit someone else's comment", () => {
const c = makeComment({ author_id: ALICE });
expect(canEditComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("denies non-author non-admin", () => {
const c = makeComment({ author_id: ALICE });
expect(canEditComment(c, { userId: BOB, role: "member" }).allowed).toBe(
false,
);
});
it("denies edit on agent-authored comments", () => {
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
const d = canEditComment(c, { userId: BOB, role: "owner" });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_resource_owner");
});
it("admin CAN delete an agent-authored comment", () => {
// delete is broader than edit — admins moderate any comment regardless of
// author type. Mirrors backend `comment.go:507-512`.
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
expect(canDeleteComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("denies plain member from deleting agent-authored comment", () => {
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
expect(
canDeleteComment(c, { userId: BOB, role: "member" }).allowed,
).toBe(false);
});
});
describe("canDeleteRuntime", () => {
it("allows the owner", () => {
const r = makeRuntime(ALICE);
expect(canDeleteRuntime(r, { userId: ALICE, role: "member" }).allowed)
.toBe(true);
});
it("allows workspace admin", () => {
const r = makeRuntime(ALICE);
expect(canDeleteRuntime(r, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("denies non-owner non-admin", () => {
const r = makeRuntime(ALICE);
expect(canDeleteRuntime(r, { userId: BOB, role: "member" }).allowed)
.toBe(false);
});
});
describe("workspace-level rules", () => {
it("only owner can delete workspace", () => {
expect(canDeleteWorkspace({ userId: ALICE, role: "owner" }).allowed).toBe(
true,
);
expect(canDeleteWorkspace({ userId: ALICE, role: "admin" }).allowed).toBe(
false,
);
expect(canDeleteWorkspace({ userId: ALICE, role: "member" }).allowed)
.toBe(false);
});
it("owner+admin can update settings, member cannot", () => {
expect(
canUpdateWorkspaceSettings({ userId: ALICE, role: "owner" }).allowed,
).toBe(true);
expect(
canUpdateWorkspaceSettings({ userId: ALICE, role: "admin" }).allowed,
).toBe(true);
expect(
canUpdateWorkspaceSettings({ userId: ALICE, role: "member" }).allowed,
).toBe(false);
});
it("manage members same gate as settings", () => {
expect(canManageMembers({ userId: ALICE, role: "admin" }).allowed).toBe(
true,
);
expect(canManageMembers({ userId: ALICE, role: "member" }).allowed).toBe(
false,
);
});
});
describe("canChangeMemberRole", () => {
const ctxOwner = { userId: ALICE, role: "owner" as const };
const ctxAdmin = { userId: ALICE, role: "admin" as const };
const ctxMember = { userId: ALICE, role: "member" as const };
const targetOwner: Pick<Member, "role"> = { role: "owner" };
const targetAdmin: Pick<Member, "role"> = { role: "admin" };
const targetMember: Pick<Member, "role"> = { role: "member" };
it("non-managers cannot change roles", () => {
expect(canChangeMemberRole(targetMember, 2, ctxMember).allowed).toBe(false);
});
it("admin cannot change owner's role", () => {
const d = canChangeMemberRole(targetOwner, 2, ctxAdmin);
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_owner_role");
});
it("admin can change admin/member roles", () => {
expect(canChangeMemberRole(targetAdmin, 1, ctxAdmin).allowed).toBe(true);
expect(canChangeMemberRole(targetMember, 1, ctxAdmin).allowed).toBe(true);
});
it("owner cannot demote the last owner", () => {
const d = canChangeMemberRole(targetOwner, 1, ctxOwner);
expect(d.allowed).toBe(false);
expect(d.reason).toBe("last_owner");
});
it("owner can change owner role when 2+ owners exist", () => {
expect(canChangeMemberRole(targetOwner, 2, ctxOwner).allowed).toBe(true);
});
});

View File

@@ -0,0 +1,210 @@
import type {
Agent,
Comment,
Member,
MemberRole,
RuntimeDevice,
Skill,
} from "../types";
import { ALLOW, deny, type Decision, type PermissionContext } from "./types";
/**
* Pure permission rules — single source of truth that mirrors the Go backend
* gates in `server/internal/handler/`. Hooks in `use-resource-permissions.ts`
* are thin wrappers that pull `PermissionContext` from auth + member queries
* and forward to these.
*
* Returning a `Decision` (not a boolean) lets every surface — disabled state,
* tooltip, banner copy — read the same `reason` and stay consistent without
* sprinkling copy through the view layer.
*/
const isAdminLike = (role: MemberRole | null) =>
role === "owner" || role === "admin";
// ---- Agents ----------------------------------------------------------------
/**
* Update / archive / restore agent fields. The backend gates archive and
* restore identically to edit (`server/internal/handler/agent.go:519-535`),
* so callers can use `canEditAgent` for all three.
*/
export function canEditAgent(agent: Agent, ctx: PermissionContext): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to edit this agent.");
}
if (isAdminLike(ctx.role)) return ALLOW;
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
return deny(
"not_resource_owner",
"Only the agent owner and workspace admins can edit this agent.",
);
}
/**
* Assign an agent to an issue. Workspace-visibility agents are assignable by
* any workspace member; private agents are restricted to their owner plus
* workspace admins/owners. Mirrors `issue.go:1471-1490`.
*/
export function canAssignAgentToIssue(
agent: Agent,
ctx: PermissionContext,
): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to assign agents.");
}
if (agent.visibility === "workspace") {
if (ctx.role === null) {
return deny("not_member", "Join this workspace to assign agents.");
}
return ALLOW;
}
// visibility === "private"
if (isAdminLike(ctx.role)) return ALLOW;
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
return deny(
"private_visibility",
"Personal agent — only the owner and workspace admins can assign work.",
);
}
// ---- Skills ----------------------------------------------------------------
export function canEditSkill(skill: Skill, ctx: PermissionContext): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to edit this skill.");
}
if (isAdminLike(ctx.role)) return ALLOW;
if (skill.created_by !== null && skill.created_by === ctx.userId) {
return ALLOW;
}
return deny(
"not_resource_owner",
"Only the creator and workspace admins can edit this skill.",
);
}
export function canDeleteSkill(skill: Skill, ctx: PermissionContext): Decision {
return canEditSkill(skill, ctx);
}
// ---- Comments --------------------------------------------------------------
export function canEditComment(
comment: Comment,
ctx: PermissionContext,
): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to edit comments.");
}
// Only member-authored comments can be edited; agent-authored comments are
// immutable from any human's perspective.
if (comment.author_type !== "member") {
return deny(
"not_resource_owner",
"Agent-authored comments cannot be edited.",
);
}
if (comment.author_id === ctx.userId) return ALLOW;
if (isAdminLike(ctx.role)) return ALLOW;
return deny(
"not_resource_owner",
"Only the author and workspace admins can edit this comment.",
);
}
export function canDeleteComment(
comment: Comment,
ctx: PermissionContext,
): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to delete comments.");
}
if (comment.author_type === "member" && comment.author_id === ctx.userId) {
return ALLOW;
}
if (isAdminLike(ctx.role)) return ALLOW;
return deny(
"not_resource_owner",
"Only the author and workspace admins can delete this comment.",
);
}
// ---- Runtimes --------------------------------------------------------------
export function canDeleteRuntime(
runtime: RuntimeDevice,
ctx: PermissionContext,
): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to delete runtimes.");
}
if (isAdminLike(ctx.role)) return ALLOW;
if (runtime.owner_id !== null && runtime.owner_id === ctx.userId) {
return ALLOW;
}
return deny(
"not_resource_owner",
"Only the runtime owner and workspace admins can delete this runtime.",
);
}
// ---- Workspace -------------------------------------------------------------
export function canUpdateWorkspaceSettings(ctx: PermissionContext): Decision {
if (isAdminLike(ctx.role)) return ALLOW;
return deny(
"not_admin_role",
"Only workspace owners and admins can update workspace settings.",
);
}
export function canDeleteWorkspace(ctx: PermissionContext): Decision {
if (ctx.role === "owner") return ALLOW;
return deny(
"not_owner_role",
"Only the workspace owner can delete this workspace.",
);
}
export function canManageMembers(ctx: PermissionContext): Decision {
if (isAdminLike(ctx.role)) return ALLOW;
return deny(
"not_admin_role",
"Only workspace owners and admins can manage members.",
);
}
/**
* Encodes the role-change matrix from `workspace.go:458-530`:
* - admins cannot touch the owner role (neither demote owners nor promote)
* - the last owner cannot be demoted
* - non-managers cannot change roles at all
*
* `ownerCount` is the number of workspace members currently with role=owner.
* Caller derives it locally from the cached member list.
*/
export function canChangeMemberRole(
target: Pick<Member, "role">,
ownerCount: number,
ctx: PermissionContext,
): Decision {
const manage = canManageMembers(ctx);
if (!manage.allowed) return manage;
if (target.role === "owner") {
if (ctx.role !== "owner") {
return deny(
"not_owner_role",
"Only the workspace owner can change another owner's role.",
);
}
if (ownerCount <= 1) {
return deny(
"last_owner",
"Promote another member to owner first — a workspace must keep at least one owner.",
);
}
}
return ALLOW;
}

View File

@@ -0,0 +1,52 @@
import type { MemberRole } from "../types";
/**
* Inputs to every permission rule. Stays role-typed so we don't have to thread
* `MemberWithUser` (with PII) into pure logic — only what we actually need.
*
* `userId === null` models the logged-out edge case; `role === null` models the
* "not a workspace member" / "member list still loading" case. Both must
* gracefully deny without throwing.
*/
export interface PermissionContext {
userId: string | null;
role: MemberRole | null;
}
/**
* Stable enum of *why* a permission was denied (or allowed). Lets UIs pick
* different copy / disabled states / banner variants without parsing the
* `message` string. Tests assert on `reason`.
*/
export type DecisionReason =
| "allowed"
| "not_authenticated"
| "not_member"
| "not_owner_role"
| "not_admin_role"
| "not_resource_owner"
| "last_owner"
| "private_visibility"
| "unknown";
export interface Decision {
allowed: boolean;
reason: DecisionReason;
/**
* Human-readable copy for tooltips / banners. Centralised here so view code
* doesn't drift. UI may still wrap it for emphasis but should not invent
* its own copy.
*/
message: string;
}
/** Builder helpers — keeps rules.ts tight. */
export const ALLOW: Decision = {
allowed: true,
reason: "allowed",
message: "",
};
export function deny(reason: DecisionReason, message: string): Decision {
return { allowed: false, reason, message };
}

View File

@@ -0,0 +1,32 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "../auth";
import type { MemberRole, MemberWithUser } from "../types";
import { memberListOptions } from "../workspace/queries";
/**
* Resolves the current user's membership in the given workspace. Single source
* of truth for "what role am I" — replaces ad-hoc `members.find(...)` lookups
* scattered across the views.
*
* `wsId` is explicit (not via `useWorkspaceId()` Context) so this hook stays
* usable in components that may render before workspace context is wired,
* matching the repo rule for workspace-aware hooks.
*/
export function useCurrentMember(wsId: string): {
userId: string | null;
role: MemberRole | null;
member: MemberWithUser | null;
isLoading: boolean;
} {
const userId = useAuthStore((s) => s.user?.id ?? null);
const { data: members, isLoading } = useQuery(memberListOptions(wsId));
const member = members?.find((m) => m.user_id === userId) ?? null;
return {
userId,
role: member?.role ?? null,
member,
isLoading,
};
}

View File

@@ -0,0 +1,65 @@
"use client";
import type { Agent, Skill } from "../types";
import { useCurrentMember } from "./use-current-member";
import {
canAssignAgentToIssue,
canDeleteSkill,
canEditAgent,
canEditSkill,
} from "./rules";
import { deny, type Decision } from "./types";
const PENDING: Decision = deny("unknown", "");
/**
* Per-resource hook that returns a `Decision` for every relevant capability.
* Each hook calls `useCurrentMember()` once and threads the context into the
* pure rules in `rules.ts`.
*
* `wsId` is explicit (not read from `WorkspaceIdProvider`) so the hook stays
* usable outside a workspace context — matches the repo rule for
* workspace-aware hooks.
*
* Resource = `null` collapses every Decision to a denied "unknown" — keeps
* callers branch-free during loading.
*
* `canArchive` / `canRestore` / `canManage` are deliberately not exposed:
* the backend gates them identically to `canEdit`, so callers can use
* `canEdit` everywhere and read better at the call site.
*/
export function useAgentPermissions(
agent: Agent | null,
wsId: string,
): {
canEdit: Decision;
canAssign: Decision;
} {
const { userId, role } = useCurrentMember(wsId);
const ctx = { userId, role };
if (agent === null) {
return { canEdit: PENDING, canAssign: PENDING };
}
return {
canEdit: canEditAgent(agent, ctx),
canAssign: canAssignAgentToIssue(agent, ctx),
};
}
export function useSkillPermissions(
skill: Skill | null,
wsId: string,
): {
canEdit: Decision;
canDelete: Decision;
} {
const { userId, role } = useCurrentMember(wsId);
const ctx = { userId, role };
if (skill === null) {
return { canEdit: PENDING, canDelete: PENDING };
}
return {
canEdit: canEditSkill(skill, ctx),
canDelete: canDeleteSkill(skill, ctx),
};
}

View File

@@ -0,0 +1,54 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { ProjectStatus, ProjectPriority } from "../types";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
interface ProjectDraft {
title: string;
description: string;
status: ProjectStatus;
priority: ProjectPriority;
leadType?: "member" | "agent";
leadId?: string;
icon?: string;
}
const EMPTY_DRAFT: ProjectDraft = {
title: "",
description: "",
status: "planned",
priority: "none",
leadType: undefined,
leadId: undefined,
icon: undefined,
};
interface ProjectDraftStore {
draft: ProjectDraft;
setDraft: (patch: Partial<ProjectDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useProjectDraftStore = create<ProjectDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () =>
set({ draft: { ...EMPTY_DRAFT } }),
hasDraft: () => {
const { draft } = get();
return !!(draft.title || draft.description);
},
}),
{
name: "multica_project_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useProjectDraftStore.persist.rehydrate());

View File

@@ -1,2 +1,9 @@
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
export { useProjectDraftStore } from "./draft-store";
export {
projectResourceKeys,
projectResourcesOptions,
useCreateProjectResource,
useDeleteProjectResource,
} from "./resource-queries";

View File

@@ -0,0 +1,87 @@
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { projectKeys } from "./queries";
import type {
CreateProjectResourceRequest,
ListProjectResourcesResponse,
ProjectResource,
} from "../types";
export const projectResourceKeys = {
list: (wsId: string, projectId: string) =>
[...projectKeys.detail(wsId, projectId), "resources"] as const,
};
export function projectResourcesOptions(wsId: string, projectId: string) {
return queryOptions({
queryKey: projectResourceKeys.list(wsId, projectId),
queryFn: () => api.listProjectResources(projectId),
select: (data) => data.resources,
});
}
export function useCreateProjectResource(wsId: string, projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateProjectResourceRequest) =>
api.createProjectResource(projectId, data),
onSuccess: (created) => {
qc.setQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
(old) =>
old && !old.resources.some((r) => r.id === created.id)
? {
...old,
resources: [...old.resources, created],
total: old.total + 1,
}
: old,
);
},
onSettled: () => {
qc.invalidateQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
},
});
}
export function useDeleteProjectResource(wsId: string, projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (resourceId: string) =>
api.deleteProjectResource(projectId, resourceId),
onMutate: async (resourceId) => {
await qc.cancelQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
const prev = qc.getQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
);
qc.setQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
(old) =>
old
? {
...old,
resources: old.resources.filter(
(r: ProjectResource) => r.id !== resourceId,
),
total: old.total - 1,
}
: old,
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) {
qc.setQueryData(projectResourceKeys.list(wsId, projectId), ctx.prev);
}
},
onSettled: () => {
qc.invalidateQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
},
});
}

View File

@@ -14,7 +14,12 @@ import { projectKeys } from "../projects/queries";
import { pinKeys } from "../pins/queries";
import { autopilotKeys } from "../autopilots/queries";
import { runtimeKeys } from "../runtimes/queries";
import { agentTaskSnapshotKeys, agentActivityKeys, agentRunCountsKeys } from "../agents/queries";
import {
agentTaskSnapshotKeys,
agentActivityKeys,
agentRunCountsKeys,
agentTasksKeys,
} from "../agents/queries";
import {
onIssueCreated,
onIssueUpdated,
@@ -46,9 +51,13 @@ import type {
SubscriberAddedPayload,
SubscriberRemovedPayload,
TaskMessagePayload,
TaskQueuedPayload,
TaskDispatchPayload,
TaskCompletedPayload,
TaskFailedPayload,
TaskCancelledPayload,
ChatDonePayload,
ChatPendingTask,
InvitationCreatedPayload,
} from "../types";
@@ -162,6 +171,15 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
// 30-day run count likewise increments per task lifecycle event.
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
// Per-agent task list (Activity tab "Recent work"). Prefix match
// catches every agent's list — the per-agent detail key sits
// under agentTasks/<wsId>/<agentId>.
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
// Per-issue task list (issue-detail Execution log). Prefix match
// across all issues — keeps the contract "any task: event makes
// every list-of-tasks query stale" so cache stays fresh even
// when the relevant component isn't currently mounted.
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
},
};
@@ -511,6 +529,64 @@ export function useRealtimeSync(
invalidateSessionLists();
});
// Chat task lifecycle writethrough: keep `chatKeys.pendingTask(sessionId)`
// synchronized with the server state machine via setQueryData rather than
// invalidate-refetch. Same pattern as task:message — the WS payload
// carries everything we need, and an HTTP roundtrip just to read what we
// already know would add latency to every stage transition.
//
// task:queued is emitted by EnqueueChatTask. The optimistic seed in
// chat-window.tsx may have already populated the cache with a temporary
// id; this handler upgrades it to the real task_id (and reaffirms status
// when reconnect replays the event for an already-running task).
const unsubTaskQueued = ws.on("task:queued", (p) => {
const payload = p as TaskQueuedPayload;
if (!payload.chat_session_id) return;
qc.setQueryData<ChatPendingTask>(
chatKeys.pendingTask(payload.chat_session_id),
(old) => ({
...(old ?? {}),
task_id: payload.task_id,
status: "queued",
}),
);
invalidatePendingAggregate();
});
// task:dispatch fires when the daemon claims the queued task. The daemon
// immediately follows with StartTask, so dispatched→running is sub-second.
// We collapse that window by writing "running" directly — the pill jumps
// from "Queued" straight to "Thinking", skipping a meaningless "Starting"
// frame. Stage decision in TaskStatusPill maps "running" + empty
// taskMessages → "Thinking · Ns".
const unsubTaskDispatch = ws.on("task:dispatch", (p) => {
const payload = p as TaskDispatchPayload;
if (!payload.chat_session_id) return;
qc.setQueryData<ChatPendingTask>(
chatKeys.pendingTask(payload.chat_session_id),
(old) => {
if (!old || old.task_id !== payload.task_id) return old;
return { ...old, status: "running" };
},
);
});
// task:cancelled reaches us when:
// 1. handleStop already cleared the cache locally (this is a no-op confirm)
// 2. another tab / admin / system cancels — this is the only path that
// drops the pending pill in those cases. Without it the pill spins
// forever in the second-tab scenario.
const unsubTaskCancelled = ws.on("task:cancelled", (p) => {
const payload = p as TaskCancelledPayload;
if (!payload.chat_session_id) return;
chatWsLogger.info("task:cancelled (global, chat)", {
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
invalidatePendingAggregate();
});
const unsubTaskCompleted = ws.on("task:completed", (p) => {
const payload = p as TaskCompletedPayload;
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
@@ -531,8 +607,14 @@ export function useRealtimeSync(
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
// No new message; just flip the pending signal.
// FailTask writes a failure chat_message (mirroring CompleteTask's
// success message), so this path mirrors the task:completed handler:
// clear the pending signal AND invalidate the messages list so the
// failure bubble shows up without requiring a page refresh. Pre-#1823
// this branch only flipped pending — the comment "No new message"
// was true then, but FailTask now persists a row.
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
});
@@ -570,6 +652,9 @@ export function useRealtimeSync(
unsubTaskMessage();
unsubChatMessage();
unsubChatDone();
unsubTaskQueued();
unsubTaskDispatch();
unsubTaskCancelled();
unsubTaskCompleted();
unsubTaskFailed();
unsubChatSessionRead();

View File

@@ -0,0 +1,61 @@
/**
* Frontend mirror of the server's MinQuickCreateCLIVersion gate. The
* agent-create flow (Quick Create modal) requires the daemon's bundled
* multica CLI to be at least this version — older daemons either
* double-create issues on partial CLI failures or mishandle pasted
* screenshot URLs (see PR #1851 / MUL-1496).
*
* Both the frontend pre-validation in the modal and the server's
* `/api/issues/quick-create` handler enforce this; the server is the
* authoritative trust boundary, the frontend just lets us tell the user
* "your daemon needs an upgrade" before they hit submit.
*/
export const MIN_QUICK_CREATE_CLI_VERSION = "0.2.20";
export type CliVersionState = "ok" | "too_old" | "missing";
export interface CliVersionCheck {
state: CliVersionState;
/** What the daemon reported, or empty if missing/unparsable. */
current: string;
/** The hard minimum we gate on. */
min: string;
}
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
function parseSemver(raw: string): [number, number, number] | null {
const m = SEMVER_RE.exec(raw.trim());
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
function lessThan(a: [number, number, number], b: [number, number, number]) {
if (a[0] !== b[0]) return a[0] < b[0];
if (a[1] !== b[1]) return a[1] < b[1];
return a[2] < b[2];
}
/**
* Check a daemon-reported CLI version string against the minimum. Returns
* `"missing"` for empty/unparsable input (fail closed — same policy as the
* server) and `"too_old"` for a parsable version below the threshold.
*/
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
const current = (detected ?? "").trim();
const parsed = current ? parseSemver(current) : null;
if (!parsed) {
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };
}
const min = parseSemver(MIN_QUICK_CREATE_CLI_VERSION)!;
if (lessThan(parsed, min)) {
return { state: "too_old", current, min: MIN_QUICK_CREATE_CLI_VERSION };
}
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
}
/** Pull `cli_version` off a runtime row's loosely-typed metadata bag. */
export function readRuntimeCliVersion(metadata: Record<string, unknown> | undefined): string {
const v = metadata?.cli_version;
return typeof v === "string" ? v : "";
}

View File

@@ -6,3 +6,4 @@ export * from "./local-skills";
export * from "./types";
export * from "./derive-health";
export * from "./use-runtime-health";
export * from "./cli-version";

View File

@@ -74,6 +74,27 @@ export interface AgentTask {
chat_session_id?: string;
/** Non-empty when the task was spawned by an autopilot run. */
autopilot_run_id?: string;
/** Set when this task was created as an auto-retry of a parent task. */
parent_task_id?: string;
/** 1-based attempt counter; >1 means this is a retry. */
attempt?: number;
/** Set when an issue comment triggered this task (@mention or assignee comment). */
trigger_comment_id?: string;
/**
* Canonical short description of what triggered this task — snapshot
* taken at creation time. For comment-triggered tasks it's the
* comment text (truncated to ~200 chars); for autopilot it's the
* autopilot title; NULL for direct assignments and chat tasks.
* Persists even if the source comment / autopilot is later edited
* or deleted.
*/
trigger_summary?: string;
/**
* Server-computed source discriminator used by the activity row to label
* tasks that have no linked issue (so e.g. quick-create tasks render
* with a meaningful title instead of falling through to "Untracked").
*/
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
}
export interface Agent {

View File

@@ -28,18 +28,48 @@ export interface ChatMessage {
content: string;
task_id: string | null;
created_at: string;
/**
* When set, this is an assistant message synthesized by the server's
* FailTask fallback (mirrors the issue path's failure system comment).
* `content` carries the raw daemon-reported errMsg; the front-end maps
* `failure_reason` (an enum like "agent_error" / "connection_error" /
* "timeout") to a user-facing label and renders a destructive bubble.
* Null on success messages and on user messages.
*/
failure_reason?: string | null;
/**
* Wall-clock duration from `task.created_at` (user hit send) to terminal
* state (completed/failed). Set by the server on assistant messages
* synthesized by CompleteTask/FailTask. UI renders it as "Replied in
* 38s" / "Failed after 12s" beneath the bubble. Null on user messages
* and on legacy assistant messages predating migration 063.
*/
elapsed_ms?: number | null;
}
export interface SendChatMessageResponse {
message_id: string;
task_id: string;
/**
* Server-authoritative task creation time. Optimistic StatusPill seed
* uses this as its anchor so the timer starts from the real `0s` —
* without it the front-end falls back to its local clock and the
* timer "snaps backwards" later when WS events update the cache.
*/
created_at: string;
}
/**
* Response from GET /api/chat/sessions/{id}/pending-task.
* Both fields are absent when the session has no in-flight task.
* All fields are absent when the session has no in-flight task.
*
* `created_at` is the server-authoritative anchor for the chat StatusPill's
* elapsed-seconds timer — the optimistic seed in chat-window.tsx fills in
* task_id/status only, then this query catches up with the real created_at
* so the timer survives refresh / reopen without "resetting to 0s".
*/
export interface ChatPendingTask {
task_id?: string;
status?: string;
created_at?: string;
}

View File

@@ -19,6 +19,7 @@ export type WSEventType =
| "agent:created"
| "agent:archived"
| "agent:restored"
| "task:queued"
| "task:dispatch"
| "task:progress"
| "task:completed"
@@ -195,6 +196,22 @@ export interface TaskMessagePayload {
output?: string;
}
export interface TaskQueuedPayload {
task_id: string;
agent_id: string;
issue_id: string;
chat_session_id?: string;
status: string;
}
export interface TaskDispatchPayload {
task_id: string;
agent_id: string;
issue_id: string;
runtime_id: string;
chat_session_id?: string;
}
export interface TaskCompletedPayload {
task_id: string;
agent_id: string;

View File

@@ -38,6 +38,7 @@ export type {
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
@@ -47,7 +48,19 @@ export type * from "./api";
export type { Attachment } from "./attachment";
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
export type { StorageAdapter } from "./storage";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type {
Project,
ProjectStatus,
ProjectPriority,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
ProjectResourceType,
GithubRepoResourceRef,
CreateProjectResourceRequest,
ListProjectResourcesResponse,
} from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
Autopilot,

View File

@@ -0,0 +1,15 @@
export type NotificationGroupKey =
| "assignments"
| "status_changes"
| "comments"
| "updates"
| "agent_activity";
export type NotificationGroupValue = "all" | "muted";
export type NotificationPreferences = Partial<Record<NotificationGroupKey, NotificationGroupValue>>;
export interface NotificationPreferenceResponse {
workspace_id: string;
preferences: NotificationPreferences;
}

View File

@@ -26,6 +26,9 @@ export interface CreateProjectRequest {
priority?: ProjectPriority;
lead_type?: "member" | "agent";
lead_id?: string;
// Resources to attach in the same transaction as the project. Server returns
// 4xx (and rolls back) if any one is invalid or duplicate.
resources?: CreateProjectResourceRequest[];
}
export interface UpdateProjectRequest {
@@ -42,3 +45,39 @@ export interface ListProjectsResponse {
projects: Project[];
total: number;
}
// ProjectResource is a typed pointer from a project to an external resource.
// The resource_ref shape depends on resource_type (e.g. github_repo carries
// { url, default_branch_hint? }). New types add a case in
// validateAndNormalizeResourceRef on the server and a renderer in the UI;
// no schema or type changes required.
export type ProjectResourceType = "github_repo";
export interface GithubRepoResourceRef {
url: string;
default_branch_hint?: string;
}
export interface ProjectResource {
id: string;
project_id: string;
workspace_id: string;
resource_type: ProjectResourceType;
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
label: string | null;
position: number;
created_at: string;
created_by: string | null;
}
export interface CreateProjectResourceRequest {
resource_type: ProjectResourceType;
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
label?: string;
position?: number;
}
export interface ListProjectResourcesResponse {
resources: ProjectResource[];
total: number;
}

View File

@@ -2,7 +2,6 @@ export type MemberRole = "owner" | "admin" | "member";
export interface WorkspaceRepo {
url: string;
description: string;
}
export interface Workspace {

View File

@@ -0,0 +1,90 @@
import { Lock } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
type Resource = "agent" | "skill" | "comment" | "runtime" | "workspace";
type Reason =
| "allowed"
| "not_authenticated"
| "not_member"
| "not_owner_role"
| "not_admin_role"
| "not_resource_owner"
| "last_owner"
| "private_visibility"
| "unknown";
const RESOURCE_NOUN: Record<Resource, string> = {
agent: "agent",
skill: "skill",
comment: "comment",
runtime: "runtime",
workspace: "workspace",
};
/**
* Read-only banner for resource detail pages — appears when the current user
* cannot edit the resource. Single component owns all the copy variants so
* the wording stays consistent across agent, skill, runtime detail pages.
*
* Returns `null` when the user *can* edit (reason === "allowed") so callers
* can mount it unconditionally.
*/
export function CapabilityBanner({
reason,
resource,
ownerName,
className,
}: {
reason: Reason;
resource: Resource;
/** Display name of the resource owner / creator. Optional — copy degrades gracefully. */
ownerName?: string;
className?: string;
}) {
if (reason === "allowed" || reason === "unknown") return null;
const noun = RESOURCE_NOUN[resource];
const message = getCopy(reason, noun, ownerName);
return (
<div
role="status"
className={cn(
"flex items-center gap-2 rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground",
className,
)}
>
<Lock className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>{message}</span>
</div>
);
}
function getCopy(reason: Reason, noun: string, ownerName?: string): string {
switch (reason) {
case "not_authenticated":
return `Sign in to edit this ${noun}.`;
case "not_member":
return `Join this workspace to edit this ${noun}.`;
case "not_owner_role":
return `View only — only the workspace owner can manage this ${noun}.`;
case "not_admin_role":
return `View only — only workspace owners and admins can manage this ${noun}.`;
case "not_resource_owner":
if (ownerName) {
return `View only — only ${ownerName} and workspace admins can edit this ${noun}.`;
}
return `View only — only the ${noun} owner and workspace admins can edit this ${noun}.`;
case "last_owner":
return `A workspace must keep at least one owner — promote another member first.`;
case "private_visibility":
if (ownerName) {
return `Personal ${noun} — only ${ownerName} and workspace admins can use this.`;
}
return `Personal ${noun} — only the owner and workspace admins can use this.`;
case "allowed":
case "unknown":
return ""; // unreachable; component returned null above
}
}

View File

@@ -36,6 +36,8 @@ function FileUploadButton({
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
aria-label="Attach file"
title="Attach file"
className={cn(
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
btnSize,

View File

@@ -0,0 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import spinners, { type BrailleSpinnerName } from "unicode-animations";
interface Props {
name?: BrailleSpinnerName;
className?: string;
/** Stop advancing frames without unmounting (e.g., when an outer state freezes). */
paused?: boolean;
}
// Inline-rendered braille spinner. Each frame is a unicode string from the
// `unicode-animations` package; we tick frames on the spinner's own `interval`
// and render the current one inside a fixed-width monospace span so different
// frames never reflow neighbouring text. Width-jitter is the main reason this
// component exists rather than dropping the raw strings into Tailwind classes.
export function UnicodeSpinner({ name = "braille", className, paused }: Props) {
const spec = spinners[name];
const [frame, setFrame] = useState(0);
useEffect(() => {
if (paused) return;
setFrame(0);
const timer = setInterval(
() => setFrame((f) => (f + 1) % spec.frames.length),
spec.interval,
);
return () => clearInterval(timer);
}, [name, paused, spec]);
return (
<span
aria-hidden="true"
className={className}
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
display: "inline-block",
minWidth: "1ch",
textAlign: "center",
fontVariantNumeric: "tabular-nums",
}}
>
{spec.frames[frame]}
</span>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import type { Column } from "@tanstack/react-table";
import {
ChevronDown,
ChevronsUpDown,
ChevronUp,
EyeOff,
X,
} from "lucide-react";
import type * as React from "react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { cn } from "@multica/ui/lib/utils";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.ComponentProps<typeof DropdownMenuTrigger> {
column: Column<TData, TValue>;
label: string;
}
// Sort/hide-aware column header, adapted from Dice UI
// (https://diceui.com/r/data-table). Renders the label as plain text when
// the column has neither sorting nor hiding enabled (so non-interactive
// columns don't expose a useless dropdown). Otherwise wraps the label in
// a dropdown-menu trigger that toggles sort direction and hides the
// column on demand.
export function DataTableColumnHeader<TData, TValue>({
column,
label,
className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort() && !column.getCanHide()) {
return <div className={cn(className)}>{label}</div>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"-ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
className,
)}
{...props}
>
{label}
{column.getCanSort() &&
(column.getIsSorted() === "desc" ? (
<ChevronDown />
) : column.getIsSorted() === "asc" ? (
<ChevronUp />
) : (
<ChevronsUpDown />
))}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-28">
{column.getCanSort() && (
<>
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsSorted() === "asc"}
onClick={() => column.toggleSorting(false)}
>
<ChevronUp />
Asc
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsSorted() === "desc"}
onClick={() => column.toggleSorting(true)}
>
<ChevronDown />
Desc
</DropdownMenuCheckboxItem>
{column.getIsSorted() && (
<DropdownMenuItem
className="pl-2 [&_svg]:text-muted-foreground"
onClick={() => column.clearSorting()}
>
<X />
Reset
</DropdownMenuItem>
)}
</>
)}
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={!column.getIsVisible()}
onClick={() => column.toggleVisibility(false)}
>
<EyeOff />
Hide
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,319 @@
"use client";
import {
flexRender,
type Header as TanstackHeader,
type Row,
type Table as TanstackTable,
} from "@tanstack/react-table";
import * as React from "react";
// We deliberately use the lower-level shadcn primitives (TableHeader /
// TableBody / TableRow / TableHead / TableCell) but NOT the wrapping
// <Table> component. shadcn's <Table> nests the <table> inside an
// `overflow-x-auto` <div>, which would compete with our outer scroll
// container and pin the horizontal scrollbar to the bottom of the
// table rather than the viewport.
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@multica/ui/components/ui/table";
import { getCellStyle } from "@multica/ui/lib/data-table";
import { cn } from "@multica/ui/lib/utils";
interface DataTableProps<TData> extends React.ComponentProps<"div"> {
table: TanstackTable<TData>;
// Optional bar shown below the table when ≥1 row is selected. We
// don't currently use selection — kept on the API surface for parity
// with Dice UI's component so future row-select features just work.
actionBar?: React.ReactNode;
// Override for the empty-state cell text.
emptyMessage?: React.ReactNode;
// Called when the user clicks a row (anywhere outside an interactive
// descendant — buttons / dropdowns inside cells should call
// event.stopPropagation in their own handlers). Used to navigate to
// a detail page on row click without nesting an <a> around <tr>,
// which is invalid HTML.
onRowClick?: (row: Row<TData>) => void;
}
// Headless data-table shell — adapted from Dice UI's data-table
// registry (https://diceui.com/r/data-table). Renders a TanStack Table
// instance using shadcn/ui's table primitives.
//
// Layout behaviour:
// - `w-full` + `table-fixed` keeps the table at viewport width and
// makes each column's width come from its first row's <th>
// inline width. column.size is authoritative for sized columns.
// - Columns flagged `meta.grow: true` skip their inline width, so
// fixed table-layout assigns them the leftover space until the user
// resizes them. Once resized, the explicit width is applied.
// - The table's `min-width` is the sum of every column's TanStack
// size (`table.getTotalSize()`). That gives grow columns a real
// floor — fixed mode ignores cell-level min-width, but it does
// respect `min-width` on the table itself. When the container is
// wider than min-width the table tracks it; when narrower, the
// table pins to min-width and the outer overflow-auto scrolls.
export function DataTable<TData>({
table,
actionBar,
emptyMessage = "No results.",
onRowClick,
className,
...props
}: DataTableProps<TData>) {
const [resizingColumnId, setResizingColumnId] = React.useState<string | null>(
null,
);
const columnSizing = table.getState().columnSizing;
const hasExplicitSize = React.useCallback(
(columnId: string) =>
Object.prototype.hasOwnProperty.call(columnSizing, columnId),
[columnSizing],
);
const setColumnWidth = React.useCallback(
(header: TanstackHeader<TData, unknown>, width: number) => {
const minSize = header.column.columnDef.minSize ?? 48;
const maxSize =
header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
const next = Math.min(maxSize, Math.max(minSize, Math.round(width)));
table.setColumnSizing((old) => ({
...old,
[header.column.id]: next,
}));
},
[table],
);
const beginColumnResize = React.useCallback(
(
header: TanstackHeader<TData, unknown>,
event: React.PointerEvent<HTMLDivElement>,
) => {
if (!header.column.getCanResize()) return;
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const headerCell = event.currentTarget.closest("th");
const startWidth =
headerCell?.getBoundingClientRect().width ?? header.column.getSize();
setResizingColumnId(header.column.id);
setColumnWidth(header, startWidth);
const originalCursor = document.body.style.cursor;
const originalUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (pointerEvent: PointerEvent) => {
setColumnWidth(header, startWidth + pointerEvent.clientX - startX);
};
const stopResize = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
window.removeEventListener("pointercancel", stopResize);
document.body.style.cursor = originalCursor;
document.body.style.userSelect = originalUserSelect;
setResizingColumnId(null);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize);
window.addEventListener("pointercancel", stopResize);
},
[setColumnWidth],
);
const handleResizeKeyDown = React.useCallback(
(
header: TanstackHeader<TData, unknown>,
event: React.KeyboardEvent<HTMLDivElement>,
) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
const headerCell = event.currentTarget.closest("th");
const currentWidth = hasExplicitSize(header.column.id)
? header.column.getSize()
: (headerCell?.getBoundingClientRect().width ??
header.column.getSize());
const direction = event.key === "ArrowRight" ? 1 : -1;
const step = event.shiftKey ? 20 : 8;
setColumnWidth(header, currentWidth + direction * step);
},
[hasExplicitSize, setColumnWidth],
);
return (
<div
className={cn("flex min-h-0 flex-1 flex-col", className)}
{...props}
>
<div className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
<table
className="w-full table-fixed caption-bottom text-sm"
style={{ minWidth: `${table.getTotalSize()}px` }}
>
<TableHeader className="sticky top-0 z-10 bg-muted/30 backdrop-blur">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
const isPinned = header.column.getIsPinned();
const columnHasExplicitSize = hasExplicitSize(
header.column.id,
);
const headerLabel =
typeof header.column.columnDef.header === "string"
? header.column.columnDef.header
: header.column.id;
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
// Header typography overrides for a "spreadsheet
// header" look: smaller, all-caps, wider letter
// spacing, muted colour. shadcn's <TableHead>
// defaults to text-sm + text-foreground +
// font-medium, which reads as too heavy here.
// h-8 (32px) tightens the strip vs the default
// h-10 (40px).
// overflow-hidden caps any cell content that
// exceeds column.size. Tooltip / dropdown /
// hover-card bodies are portaled, so they are
// unaffected.
// Pinned header cell uses muted/30 so it blends
// into the header strip rather than appearing as
// a white block under sticky scroll.
className={cn(
"relative h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
isPinned && "bg-muted/30 backdrop-blur",
)}
style={getCellStyle(header.column, {
withBorder: true,
hasExplicitSize: columnHasExplicitSize,
})}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{!header.isPlaceholder &&
header.column.getCanResize() && (
<div
role="separator"
aria-label={`Resize ${headerLabel} column`}
aria-orientation="vertical"
tabIndex={0}
className={cn(
"absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none outline-none",
"after:absolute after:top-1/2 after:right-0 after:h-4 after:w-px after:-translate-y-1/2 after:bg-border after:opacity-0 after:transition-opacity",
"hover:after:opacity-100 focus-visible:after:opacity-100",
resizingColumnId === header.column.id &&
"after:bg-primary after:opacity-100",
)}
onPointerDown={(event) =>
beginColumnResize(header, event)
}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
header.column.resetSize();
}}
onKeyDown={(event) =>
handleResizeKeyDown(header, event)
}
/>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={
onRowClick ? () => onRowClick(row) : undefined
}
// `group` lets pinned cells track row hover via
// group-hover (their bg is in className, not on the
// row, so they stay opaque enough to cover content
// scrolling beneath them).
className={cn(
"group",
onRowClick && "cursor-pointer",
)}
>
{row.getVisibleCells().map((cell) => {
const isPinned = cell.column.getIsPinned();
const columnHasExplicitSize = hasExplicitSize(
cell.column.id,
);
return (
<TableCell
key={cell.id}
// px-4 across the board so cell content
// aligns with the surrounding toolbar's
// px-4. Narrow trailing columns (chevron /
// actions) declare a column.size large enough
// to fit the icon plus 16+16 padding.
// Pinned cells need an opaque bg + group-
// hover so they cover content scrolling
// beneath them and follow row hover state.
className={cn(
"overflow-hidden px-4 py-2",
isPinned &&
"bg-background group-hover:bg-muted/50",
)}
style={getCellStyle(cell.column, {
withBorder: true,
hasExplicitSize: columnHasExplicitSize,
})}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getAllColumns().length}
className="h-24 text-center text-muted-foreground"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
{actionBar &&
table.getFilteredSelectedRowModel().rows.length > 0 &&
actionBar}
</div>
);
}

View File

@@ -21,18 +21,33 @@ function HoverCardContent({
align = "center",
alignOffset = 4,
onClick,
onContextMenu,
onAuxClick,
onDoubleClick,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
// Stop click events from bubbling out of the popover. Base UI portals the
// popup to <body> so the DOM is detached, but React's synthetic event
// Stop interaction events from bubbling out of the popup. Base UI portals
// the popup to <body> so the DOM is detached, but React's synthetic event
// system still bubbles through the React component tree — without this,
// clicks on links / buttons inside the card would also fire onClick on
// any ancestor link the trigger was nested in (e.g. issue list rows).
// Consumer-supplied onClick is forwarded after the stop.
// events on the popup would also fire on any ancestor of the trigger
// (e.g. a clickable issue list row, a wrapping <a>).
//
// We stop the safe set: click / contextmenu / auxclick / dblclick.
// We deliberately do NOT stop pointerdown / mousedown — Base UI's
// outside-click dismiss listens to pointerdown on document and uses an
// "inside React tree" check to decide whether to close. Stopping
// pointerdown inside the popup would make the dismiss handler wrongly
// think the click happened outside, requiring two clicks to close
// (mirrors radix-ui/primitives#2782).
const stop = <E extends React.SyntheticEvent>(forwarded?: (e: E) => void) =>
(e: E) => {
e.stopPropagation()
forwarded?.(e)
}
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
@@ -44,10 +59,10 @@ function HoverCardContent({
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
onClick={(e) => {
e.stopPropagation()
onClick?.(e)
}}
onClick={stop(onClick)}
onContextMenu={stop(onContextMenu)}
onAuxClick={stop(onAuxClick)}
onDoubleClick={stop(onDoubleClick)}
className={cn(
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className

View File

@@ -5,11 +5,16 @@ import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
// Use `resolvedTheme` (the concrete "light" / "dark" value) instead of
// `theme` (which can be "system"). When we forward "system", sonner reads
// `prefers-color-scheme` itself, and the Electron renderer's media query
// can disagree with next-themes' `html.dark` class — that's why the toast
// sometimes rendered light on a dark UI.
const { resolvedTheme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={resolvedTheme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (

View File

@@ -0,0 +1,57 @@
import type { Column, RowData } from "@tanstack/react-table";
import type * as React from "react";
// Extend TanStack Table's ColumnMeta with a `grow` flag. TanStack merges
// a default `size: 150` into every columnDef, so "no explicit size" can't
// be detected by inspecting columnDef.size (it's always a number). Setting
// `meta: { grow: true }` is the official extension point: DataTable skips
// the inline width for these columns until the user explicitly resizes them,
// then the resized width wins.
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
grow?: boolean;
}
}
// Combined sizing + pinning style for a `<th>` / `<td>` cell. Width is
// emitted unless the column is flagged `meta.grow` (those rely on
// fixed-layout's leftover-space distribution). Pinned columns get
// sticky positioning — see notes below.
//
// Background is intentionally NOT set inline — the upstream Dice UI
// version writes `background: var(--background)` here, which can't
// react to `:hover`. Consumers set bg via Tailwind classes paired with
// `group-hover:`.
export function getCellStyle<TData>(
column: Column<TData>,
options?: { withBorder?: boolean; hasExplicitSize?: boolean },
): React.CSSProperties {
const grow = column.columnDef.meta?.grow;
const width = grow && !options?.hasExplicitSize ? undefined : column.getSize();
const isPinned = column.getIsPinned();
if (!isPinned) {
return width !== undefined ? { width } : {};
}
const withBorder = options?.withBorder ?? false;
const isLastLeftPinned =
isPinned === "left" && column.getIsLastColumn("left");
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right");
return {
width,
position: "sticky",
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
zIndex: 1,
boxShadow: withBorder
? isLastLeftPinned
? "-4px 0 4px -4px var(--border) inset"
: isFirstRightPinned
? "4px 0 4px -4px var(--border) inset"
: undefined
: undefined,
};
}

View File

@@ -16,12 +16,14 @@
"./markdown/mentions": "./markdown/mentions.ts",
"./hooks/*": "./hooks/*.ts",
"./lib/utils": "./lib/utils.ts",
"./lib/data-table": "./lib/data-table.ts",
"./styles/tokens.css": "./styles/tokens.css",
"./styles/base.css": "./styles/base.css"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@emoji-mart/data": "^1.2.1",
"@tanstack/react-table": "catalog:",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
@@ -46,6 +48,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "catalog:",
"tw-animate-css": "^1.4.0",
"unicode-animations": "catalog:",
"vaul": "^1.1.2"
},
"peerDependencies": {

View File

@@ -83,6 +83,37 @@
animation: chat-impulse 1.6s ease-in-out infinite;
}
/* ChatGPT-style "thinking" shimmer for inline text — a soft light sweep
* runs across the glyphs, signalling "the agent is doing something" without
* a separate spinner. Pure CSS: linear-gradient clipped to the text shape,
* the gradient slid across via background-position. Uses the same muted →
* foreground tokens chat copy normally uses, so the effect adapts to light
* and dark mode without per-mode overrides.
*
* Apply to a <span> wrapping the label only — not the whole pill, since
* the timer counter and Cancel button shouldn't shimmer. */
@keyframes chat-text-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.animate-chat-text-shimmer {
background-image: linear-gradient(
90deg,
var(--muted-foreground) 0%,
var(--muted-foreground) 35%,
var(--foreground) 50%,
var(--muted-foreground) 65%,
var(--muted-foreground) 100%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
animation: chat-text-shimmer 2.5s linear infinite;
}
/* Sidebar: open triggers (dropdown/popover) get active background */
[data-sidebar="menu-button"][data-popup-open] {
background-color: var(--sidebar-accent);
@@ -118,4 +149,14 @@
* Progressive enhancement: browsers that don't support it simply ignore the rule. */
text-autospace: ideograph-alpha ideograph-numeric;
}
@media (max-width: 767px), (pointer: coarse) {
input:not([type="button"]):not([type="checkbox"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="image"]):not([type="radio"]):not([type="range"]):not([type="reset"]):not([type="submit"]),
textarea,
select,
[contenteditable]:not([contenteditable="false"]) {
/* iOS Safari zooms the page when focused editable text is below 16px. */
font-size: 16px !important;
}
}
}

View File

@@ -0,0 +1,377 @@
"use client";
import { Cloud, Lock, Monitor } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import type { Agent, AgentRuntime } from "@multica/core/types";
import {
type AgentActivity,
type AgentPresenceDetail,
summarizeActivityWindow,
VISIBILITY_TOOLTIP,
} from "@multica/core/agents";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { availabilityConfig, workloadConfig } from "../presence";
import { AgentRowActions } from "./agent-row-actions";
import { Sparkline } from "./sparkline";
// Per-row data shape. We assemble agent + runtime + presence + activity +
// run count into one struct at the page level so the column cells just
// read off `row.original` without each pulling its own queries.
export interface AgentRow {
agent: Agent;
runtime: AgentRuntime | null;
presence: AgentPresenceDetail | null | undefined;
activity: AgentActivity | null | undefined;
runCount: number;
// Inline owner avatar — non-null when the page wants to attribute the
// agent to a teammate (typically All scope on someone else's agent).
ownerIdToShow: string | null;
// True when the current user owns this agent (drives the "You" badge).
isOwnedByMe: boolean;
// True when the current user can archive / cancel-tasks on this agent.
canManage: boolean;
}
// Sized columns render at exactly `size` in fixed table-layout mode —
// column.size doubles as the cell's effective max-width: truncatable
// cells with `truncate` inside hit ellipsis at the column edge.
//
// The Agent and Runtime columns have `meta.grow: true` so DataTable skips
// their inline widths until the user resizes them. Fixed table-layout splits
// the leftover space between them, which keeps Agent from monopolising wide
// viewports while still giving both columns a real floor.
//
// The grow columns also keep their `size` values even though those widths
// are skipped for initial rendering. TanStack folds them into
// `table.getTotalSize()`, which DataTable applies as the table's `min-width`.
// That's how the grow columns get real floors: when the viewport drops below
// the summed column sizes, the table refuses to shrink further and the
// container scrolls instead.
const COL_WIDTHS = {
agent: 240,
status: 120,
workload: 140,
runtime: 200,
activity: 100,
runs: 64,
// 60 = 16 left padding + 28 kebab + 16 right padding. Keeps the
// kebab's right edge 16px from the card so it lines up with the
// toolbar's px-4 right inset.
actions: 60,
} as const;
export function createAgentColumns({
onDuplicate,
}: {
onDuplicate: (agent: Agent) => void;
}): ColumnDef<AgentRow>[] {
return [
{
id: "agent",
header: "Agent",
size: COL_WIDTHS.agent,
meta: { grow: true },
cell: ({ row }) => <AgentNameCell row={row.original} />,
},
{
id: "status",
header: "Status",
size: COL_WIDTHS.status,
cell: ({ row }) => {
if (row.original.agent.archived_at) {
return <span className="text-xs text-muted-foreground"></span>;
}
return <AvailabilityCell presence={row.original.presence} />;
},
},
{
id: "workload",
header: "Workload",
size: COL_WIDTHS.workload,
cell: ({ row }) => {
if (row.original.agent.archived_at) {
return <span className="text-xs text-muted-foreground"></span>;
}
return <WorkloadCell presence={row.original.presence} />;
},
},
{
id: "runtime",
header: "Runtime",
size: COL_WIDTHS.runtime,
meta: { grow: true },
cell: ({ row }) => <RuntimeCell row={row.original} />,
},
{
id: "activity",
header: "Activity (7d)",
size: COL_WIDTHS.activity,
cell: ({ row }) => <ActivityCell row={row.original} />,
},
{
id: "runs",
header: () => <div className="text-right">Runs</div>,
size: COL_WIDTHS.runs,
cell: ({ row }) => (
<div className="text-right font-mono text-xs tabular-nums text-muted-foreground">
{row.original.runCount == null
? "—"
: row.original.runCount.toLocaleString()}
</div>
),
},
{
id: "actions",
header: () => null,
size: COL_WIDTHS.actions,
enableResizing: false,
cell: ({ row }) => (
<div
className="flex justify-end"
// The kebab dropdown owns its own click target. Stop the row
// click handler from firing as a side-effect.
onClick={(e) => e.stopPropagation()}
>
<AgentRowActions
agent={row.original.agent}
presence={row.original.presence}
canManage={row.original.canManage}
onDuplicate={onDuplicate}
/>
</div>
),
},
];
}
// ---------------------------------------------------------------------------
// Cell renderers
// ---------------------------------------------------------------------------
function AgentNameCell({ row }: { row: AgentRow }) {
const { agent, ownerIdToShow, isOwnedByMe } = row;
const isArchived = !!agent.archived_at;
const isPrivate = agent.visibility === "private";
return (
<div className="flex min-w-0 items-center gap-3">
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={28}
className={`shrink-0 rounded-md ${isArchived ? "opacity-50 grayscale" : ""}`}
showStatusDot
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span
className={`min-w-0 truncate font-medium ${
isArchived ? "text-muted-foreground" : ""
}`}
>
{agent.name}
</span>
{isPrivate && !isArchived && (
<Tooltip>
<TooltipTrigger
render={
<Lock className="h-3 w-3 shrink-0 text-muted-foreground/60" />
}
/>
<TooltipContent>
{VISIBILITY_TOOLTIP.private}
</TooltipContent>
</Tooltip>
)}
{isOwnedByMe && !ownerIdToShow && (
<span className="shrink-0 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
You
</span>
)}
{ownerIdToShow && (
<ActorAvatar
actorType="member"
actorId={ownerIdToShow}
size={14}
/>
)}
{isArchived && (
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Archived
</span>
)}
</div>
<div
className={`mt-0.5 truncate text-xs ${
agent.description
? "text-muted-foreground"
: "italic text-muted-foreground/50"
}`}
>
{agent.description || "No description"}
</div>
</div>
</div>
);
}
function AvailabilityCell({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
if (!presence) {
return (
<span className="inline-flex h-3 w-16 animate-pulse rounded bg-muted/60" />
);
}
const av = availabilityConfig[presence.availability];
return (
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
</span>
);
}
function WorkloadCell({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
if (!presence) {
return (
<span className="inline-flex h-3 w-20 animate-pulse rounded bg-muted/60" />
);
}
// All three workload states render with the same shape (icon + label +
// optional counts). Idle agents show "Idle" rather than a bare em-dash
// — that hyphen used to mean both "no presence data" and "agent is
// idle", which conflated two distinct things. Em-dash is now reserved
// for archived rows / undefined presence (handled at the column level).
const wl = workloadConfig[presence.workload];
const isWorking = presence.workload === "working";
const isQueued = presence.workload === "queued";
// Queued's amber from workloadConfig is the severe tone for "stuck on
// offline runtime". On an online runtime queued is just a brief race
// between enqueue and daemon claim, where amber misreads as a warning.
// Compose with availability so the colour matches the actual signal.
const queuedTone =
presence.availability === "online" ? "text-muted-foreground" : wl.textClass;
const labelTone = isQueued ? queuedTone : wl.textClass;
// Working: show running/capacity, optionally with +Nq when overflow.
// Queued (= nothing running, things waiting — typically a stuck-on-
// offline-runtime signal): show the queued count directly so the user
// sees "Queued · 2" instead of misleading "Running 0/3 +2q".
// Idle: no counts — the label alone carries the meaning.
const counts = isWorking
? presence.queuedCount > 0
? `${presence.runningCount}/${presence.capacity} +${presence.queuedCount}q`
: `${presence.runningCount}/${presence.capacity}`
: isQueued
? `${presence.queuedCount}`
: null;
return (
<span className="inline-flex items-center gap-1 text-xs">
{/* Icon only renders for working/queued — those carry visual meaning
(spinner = in motion, clock = waiting). Idle adding an icon read
as a warning marker, which is the wrong signal. */}
{presence.workload !== "idle" && (
<wl.icon
className={`h-3 w-3 shrink-0 ${labelTone} ${isWorking ? "animate-spin" : ""}`}
/>
)}
<span className={`shrink-0 ${labelTone}`}>{wl.label}</span>
{counts && (
<span className="truncate text-muted-foreground">{counts}</span>
)}
</span>
);
}
function RuntimeCell({ row }: { row: AgentRow }) {
const { agent, runtime } = row;
const isCloud = agent.runtime_mode === "cloud";
const RuntimeIcon = isCloud ? Cloud : Monitor;
const runtimeLabel = runtime?.name ?? (isCloud ? "Cloud" : "Local");
return (
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<RuntimeIcon className="h-3 w-3 shrink-0" />
<Tooltip>
<TooltipTrigger
render={
<span className="block min-w-0 truncate">{runtimeLabel}</span>
}
/>
<TooltipContent>{runtimeLabel}</TooltipContent>
</Tooltip>
</div>
);
}
function ActivityCell({ row }: { row: AgentRow }) {
const { agent, activity } = row;
if (agent.archived_at) {
return <span className="text-xs text-muted-foreground/50"></span>;
}
if (!activity) {
return (
<span
className="inline-block animate-pulse rounded bg-muted/60"
style={{ width: 64, height: 20 }}
/>
);
}
const summary = summarizeActivityWindow(activity, 7);
return (
<Tooltip>
<TooltipTrigger
render={
<div className="inline-flex cursor-default items-center">
<Sparkline buckets={summary.buckets} width={64} height={20} />
</div>
}
/>
<TooltipContent>
<ActivityTooltipBody activity={activity} />
</TooltipContent>
</Tooltip>
);
}
function ActivityTooltipBody({ activity }: { activity: AgentActivity }) {
const summary = summarizeActivityWindow(activity, 7);
const { totalRuns, totalFailed } = summary;
const { daysSinceCreated } = activity;
const isPartial = daysSinceCreated < 7;
const headerText = isPartial
? `Created ${daysSinceCreated === 0 ? "today" : `${daysSinceCreated} day${daysSinceCreated === 1 ? "" : "s"} ago`}`
: "Last 7 days";
let bodyText: string;
if (totalRuns === 0) {
bodyText = "No activity";
} else {
const failedFragment =
totalFailed > 0
? ` · ${totalFailed} failed (${Math.round((totalFailed / totalRuns) * 100)}%)`
: "";
bodyText = `${totalRuns} run${totalRuns === 1 ? "" : "s"}${failedFragment}`;
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{headerText}
</span>
<span className="text-xs">{bodyText}</span>
</div>
);
}

View File

@@ -13,13 +13,23 @@ import type {
AgentRuntime,
MemberWithUser,
} from "@multica/core/types";
import type { AgentPresenceDetail } from "@multica/core/agents";
import {
AGENT_DESCRIPTION_MAX_LENGTH,
type AgentPresenceDetail,
} from "@multica/core/agents";
import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { timeAgo } from "@multica/core/utils";
import { Button } from "@multica/ui/components/ui/button";
import { ActorAvatar } from "../../common/actor-avatar";
import { Input } from "@multica/ui/components/ui/input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
Popover,
PopoverContent,
@@ -27,6 +37,7 @@ import {
} from "@multica/ui/components/ui/popover";
import { PropRow } from "../../common/prop-row";
import { availabilityConfig } from "../presence";
import { CharCounter } from "./char-counter";
import { ConcurrencyPicker } from "./inspector/concurrency-picker";
import { ModelPicker } from "./inspector/model-picker";
import { RuntimePicker } from "./inspector/runtime-picker";
@@ -44,6 +55,15 @@ interface InspectorProps {
runtimes: AgentRuntime[];
members: MemberWithUser[];
currentUserId: string | null;
/**
* Computed by the parent via `useAgentPermissions(agent).canEdit.allowed`.
* When false the inspector renders all editable surfaces as static
* read-only displays — pickers become text/badges, name/description lose
* their pencil affordance, the avatar is no longer clickable, and the
* "Attach skill" trigger is hidden. Mirrors the backend gate at
* `server/internal/handler/agent.go:519-535`.
*/
canEdit: boolean;
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
}
@@ -66,6 +86,7 @@ export function AgentDetailInspector({
runtimes,
members,
currentUserId,
canEdit,
onUpdate,
}: InspectorProps) {
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
@@ -75,16 +96,18 @@ export function AgentDetailInspector({
<aside className="flex h-full min-h-0 w-full flex-col overflow-y-auto rounded-lg border bg-background">
{/* Identity */}
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
<AvatarEditor agent={agent} onUpdate={update} />
<NameAndDescription agent={agent} onUpdate={update} />
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />
<NameAndDescription
agent={agent}
canEdit={canEdit}
onUpdate={update}
/>
<PresenceBadge presence={presence} />
</div>
{/* Properties — editable. Row hover is OFF here on purpose: each chip
(RuntimePicker, ModelPicker, …) carries its own border + hover-bg
treatment that already telegraphs "this is a button". A second
row-wide hover layer on top would just smudge the chip boundary
and make it harder, not easier, to see what's clickable. */}
{/* Properties — editable when canEdit. When the current user lacks
permission, each picker self-renders a static read-only display so
the value is visible but not interactive. */}
<Section label="Properties">
<PropRow label="Runtime" interactive={false}>
<RuntimePicker
@@ -92,6 +115,7 @@ export function AgentDetailInspector({
runtimes={runtimes}
members={members}
currentUserId={currentUserId}
canEdit={canEdit}
onChange={(id) => update({ runtime_id: id })}
/>
</PropRow>
@@ -100,18 +124,21 @@ export function AgentDetailInspector({
runtimeId={agent.runtime_id}
runtimeOnline={!!isOnline}
value={agent.model ?? ""}
canEdit={canEdit}
onChange={(m) => update({ model: m })}
/>
</PropRow>
<PropRow label="Visibility" interactive={false}>
<VisibilityPicker
value={agent.visibility}
canEdit={canEdit}
onChange={(v) => update({ visibility: v })}
/>
</PropRow>
<PropRow label="Concurrency" interactive={false}>
<ConcurrencyPicker
value={agent.max_concurrent_tasks}
canEdit={canEdit}
onChange={(n) => update({ max_concurrent_tasks: n })}
/>
</PropRow>
@@ -162,7 +189,7 @@ export function AgentDetailInspector({
{s.name}
</span>
))}
<SkillAttach agent={agent} />
<SkillAttach agent={agent} canEdit={canEdit} />
</div>
</div>
</aside>
@@ -181,11 +208,13 @@ function Section({
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-0.5 border-b px-5 py-4">
<div className="mb-1 px-2 -mx-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
<div className="border-b px-5 py-4">
<div className="mb-1 -mx-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
{children}
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
{children}
</div>
</div>
);
}
@@ -196,14 +225,29 @@ function Section({
function AvatarEditor({
agent,
canEdit,
onUpdate,
}: {
agent: Agent;
canEdit: boolean;
onUpdate: (data: Record<string, unknown>) => Promise<void>;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { upload, uploading } = useFileUpload(api);
if (!canEdit) {
return (
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={56}
className="rounded-none"
/>
</div>
);
}
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -256,11 +300,32 @@ function AvatarEditor({
function NameAndDescription({
agent,
canEdit,
onUpdate,
}: {
agent: Agent;
canEdit: boolean;
onUpdate: (data: Record<string, unknown>) => Promise<void>;
}) {
if (!canEdit) {
return (
<div className="flex flex-col gap-1">
<span className="text-base font-semibold leading-tight">
{agent.name}
</span>
{agent.description ? (
<span className="text-xs leading-relaxed text-muted-foreground">
{agent.description}
</span>
) : (
<span className="text-xs italic leading-relaxed text-muted-foreground/50">
No description
</span>
)}
</div>
);
}
return (
<div className="flex flex-col gap-1">
<InlineEditPopover
@@ -283,34 +348,139 @@ function NameAndDescription({
)}
</InlineEditPopover>
<InlineEditPopover
<DescriptionEditor
value={agent.description ?? ""}
onSave={(v) => onUpdate({ description: v })}
kind="textarea"
title="Edit description"
placeholder="What does this agent do?"
>
{(triggerProps) => (
<button
type="button"
{...triggerProps}
className="group -mx-1 inline-flex items-start gap-1.5 self-start rounded px-1 text-left text-xs leading-relaxed transition-colors hover:bg-accent/50"
>
{agent.description ? (
<span className="text-muted-foreground">{agent.description}</span>
) : (
<span className="italic text-muted-foreground/50">
No description
</span>
)}
<Pencil className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
</button>
)}
</InlineEditPopover>
/>
</div>
);
}
// Description editor — modal because the description benefits from a roomy
// composition surface (the inline popover was 288 px wide × 3 rows, too
// cramped to read or edit anything substantial). Name stays in the inline
// popover above: a single line is the right shape for it.
//
// The editor body is split into a child component that mounts only while
// the dialog is open. That way the draft state is initialised from `value`
// at mount time and never reset by an external update mid-edit — closing
// the dialog unmounts the body, reopening starts fresh with the latest
// value. This is the React-recommended replacement for the
// `useEffect(reset, [value])` anti-pattern (see "You Might Not Need an
// Effect" — Resetting state with a key / mount).
function DescriptionEditor({
value,
onSave,
}: {
value: string;
onSave: (next: string) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="group -mx-1 inline-flex items-start gap-1.5 self-start rounded px-1 text-left text-xs leading-relaxed transition-colors hover:bg-accent/50"
>
{value ? (
<span className="text-muted-foreground">{value}</span>
) : (
<span className="italic text-muted-foreground/50">No description</span>
)}
<Pencil className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
{open && (
<DescriptionEditorBody
initialValue={value}
onSave={onSave}
onClose={() => setOpen(false)}
/>
)}
</DialogContent>
</Dialog>
</>
);
}
function DescriptionEditorBody({
initialValue,
onSave,
onClose,
}: {
initialValue: string;
onSave: (next: string) => Promise<void>;
onClose: () => void;
}) {
const [draft, setDraft] = useState(initialValue);
const [saving, setSaving] = useState(false);
const length = [...draft].length;
const overLimit = length > AGENT_DESCRIPTION_MAX_LENGTH;
const dirty = draft !== initialValue;
const commit = async () => {
if (overLimit || !dirty) return;
setSaving(true);
try {
await onSave(draft);
onClose();
} catch {
// toast handled by parent's onUpdate
} finally {
setSaving(false);
}
};
return (
<>
<DialogHeader>
<DialogTitle>Edit description</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="What does this agent do?"
rows={6}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void commit();
}
}}
className="w-full resize-none rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-input"
/>
<CharCounter length={length} max={AGENT_DESCRIPTION_MAX_LENGTH} />
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={onClose}
disabled={saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => void commit()}
disabled={saving || overLimit || !dirty}
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
</Button>
</DialogFooter>
</>
);
}
// Generic single-field popover editor used for name / description. Keeps the
// trigger styling fully in the caller's hands by using a render prop.
function InlineEditPopover({

View File

@@ -24,7 +24,9 @@ import {
workspaceKeys,
} from "@multica/core/workspace/queries";
import { runtimeListOptions } from "@multica/core/runtimes";
import { useAgentPermissions } from "@multica/core/permissions";
import { Button } from "@multica/ui/components/ui/button";
import { CapabilityBanner } from "@multica/ui/components/common/capability-banner";
import {
Dialog,
DialogContent,
@@ -74,6 +76,12 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
const presence: AgentPresenceDetail | null =
agent ? presenceMap.get(agent.id) ?? null : null;
// Permission hook MUST be called unconditionally — its `agent | null`
// signature handles the not-found / loading case internally so the early
// returns below don't violate the rules of hooks. Backend gates archive
// and restore identically to edit, so a single `canEdit` covers them all.
const { canEdit } = useAgentPermissions(agent, wsId);
const [confirmArchive, setConfirmArchive] = useState(false);
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
@@ -163,23 +171,36 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
agent={agent}
presence={presence}
backHref={paths.agents()}
canArchive={canEdit.allowed}
onArchive={() => setConfirmArchive(true)}
/>
{!canEdit.allowed && (
<div className="px-6 pt-3">
<CapabilityBanner
reason={canEdit.reason}
resource="agent"
ownerName={owner?.name}
/>
</div>
)}
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/50 px-6 py-2 text-xs text-muted-foreground">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
This agent is archived. It cannot be assigned or mentioned.
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleRestore(agent.id)}
>
Restore
</Button>
{canEdit.allowed && (
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleRestore(agent.id)}
>
Restore
</Button>
)}
</div>
)}
@@ -192,6 +213,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
runtimes={runtimes}
members={members}
currentUserId={currentUser?.id ?? null}
canEdit={canEdit.allowed}
onUpdate={handleUpdate}
/>
@@ -254,11 +276,13 @@ function DetailHeader({
agent,
presence,
backHref,
canArchive,
onArchive,
}: {
agent: Agent;
presence: AgentPresenceDetail | null;
backHref: string;
canArchive: boolean;
onArchive: () => void;
}) {
const isArchived = !!agent.archived_at;
@@ -290,7 +314,7 @@ function DetailHeader({
)}
</div>
{!isArchived && (
{!isArchived && canArchive && (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" />}

View File

@@ -1,354 +0,0 @@
"use client";
import { Cloud, Lock, Monitor } from "lucide-react";
import type { Agent, AgentRuntime } from "@multica/core/types";
import {
type AgentActivity,
type AgentPresenceDetail,
summarizeActivityWindow,
} from "@multica/core/agents";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { AppLink } from "../../navigation";
import { availabilityConfig, taskStateConfig } from "../presence";
import { AgentPresenceIndicator } from "./agent-presence-indicator";
import { AgentRowActions } from "./agent-row-actions";
import { Sparkline } from "./sparkline";
// Shared grid template used by both the list header and every row, so the
// sticky header columns stay aligned with the rows below it.
//
// Why grid (not <Table table-fixed>): table-fixed forces every column to a
// declared width, which means the Status column was always reserving space
// for the worst case ("Working · 0 / 6 · +5 queued"), even when no agent in
// the workspace was in that state. Switching to grid + `max-content` lets the
// column shrink automatically when the longest cell is just "Available", and
// only widen when there's an agent that actually needs the room. The freed
// space flows into the Agent column (the primary content), via its `1.6fr`
// share — same ratio Skills uses, so the two list pages read as one family.
//
// Leading-avatar column (1.75rem) is a dedicated grid track so the header's
// "Agent" label sits on the same x as the row's name text. Without this the
// avatar (28px) + the internal flex gap pushes the name 40px right of the
// header label, which reads as misalignment. Same pattern is used in Runtimes
// (icon-box and Health dot extracted into their own tracks).
//
// Responsive strategy mirrors the previous table:
// <md → [avatar] Agent · Status (compact dot only) · Actions
// md+ → adds Last run + Runtime + Runs
// lg+ → adds Activity sparkline
// Cells use `hidden md:block` / `hidden lg:block` so they participate in the
// grid only at their breakpoint; the grid template at each tier matches the
// number of visible cells exactly.
//
// Two presence columns at md+ (Status + Last run) instead of one merged
// cell — splitting them lets the user scan each axis independently. The
// dot column is the same 3-color availability everywhere; the Last run
// column shows the task icon + label (running/completed/failed/etc).
export const AGENT_LIST_GRID =
"grid items-center gap-4 " +
"grid-cols-[1.75rem_minmax(0,1fr)_max-content_2.5rem] " +
"md:grid-cols-[1.75rem_minmax(0,1.4fr)_5rem_minmax(0,max-content)_minmax(0,0.8fr)_4rem_2.5rem] " +
"lg:grid-cols-[1.75rem_minmax(0,1.5fr)_5rem_minmax(0,max-content)_minmax(0,0.8fr)_5rem_4rem_2.5rem]";
interface AgentListItemProps {
agent: Agent;
runtime: AgentRuntime | null;
presence: AgentPresenceDetail | null | undefined;
// 30d activity series for this agent. Page derives once for the whole
// workspace and passes a row-specific slice here, so the row component
// doesn't subscribe to its own query (avoids N timers / N subscriptions).
// The list only surfaces the trailing 7 days; we still take the 30d
// shape so the cache stays aligned with the agent detail panel.
activity: AgentActivity | null | undefined;
// 30-day cumulative run count for the RUNS column. Same single-source pattern.
runCount: number | null | undefined;
// Inline owner avatar — non-null when the page wants to attribute the
// agent to a teammate (typically All scope on someone else's agent).
// The page does the "scope === all && owner !== me" decision so the row
// stays pure presentation.
ownerIdToShow: string | null;
// True when the current user can archive / cancel-tasks on this agent.
// Mirrors the back-end's canManageAgent check; the row uses it to gate
// entries in the actions dropdown.
canManage: boolean;
// Page-level callback to open Create dialog with this agent as a
// template (Duplicate action).
onDuplicate: (agent: Agent) => void;
href: string;
}
export function AgentListItem({
agent,
runtime,
presence,
activity,
runCount,
ownerIdToShow,
canManage,
onDuplicate,
href,
}: AgentListItemProps) {
const isArchived = !!agent.archived_at;
const isPrivate = agent.visibility === "private";
const isCloud = agent.runtime_mode === "cloud";
const RuntimeIcon = isCloud ? Cloud : Monitor;
const runtimeLabel = runtime?.name ?? (isCloud ? "Cloud" : "Local");
return (
<AppLink
href={href}
className={`${AGENT_LIST_GRID} group border-b px-4 py-3 text-sm transition-colors last:border-b-0 hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none`}
>
{/* Avatar — dedicated leading column so the header label "Agent"
aligns with the name text below it. */}
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={28}
className={`rounded-md ${isArchived ? "opacity-50 grayscale" : ""}`}
showStatusDot
/>
{/* Agent — primary text column, eats remaining space. */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={`truncate font-medium ${
isArchived ? "text-muted-foreground" : ""
}`}
>
{agent.name}
</span>
{/* Lock = private visibility — back-end rejects assignment by
non-owners, so flag it visually. We deliberately do NOT
filter private agents out of the list (mirrors server's
ListAgents behaviour); the icon warns the viewer that
picking this in a picker will fail. */}
{isPrivate && !isArchived && (
<Tooltip>
<TooltipTrigger
render={
<Lock className="h-3 w-3 shrink-0 text-muted-foreground/60" />
}
/>
<TooltipContent>
Private only the owner can assign work
</TooltipContent>
</Tooltip>
)}
{/* Owner attribution — only set in "All" scope when the agent
isn't yours. Tiny avatar (14px) keeps it lightweight; the
hover card on the agent's main avatar already covers the
full owner detail. */}
{ownerIdToShow && (
<ActorAvatar
actorType="member"
actorId={ownerIdToShow}
size={14}
/>
)}
{isArchived && (
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Archived
</span>
)}
</div>
<div
className={`mt-0.5 line-clamp-1 text-xs ${
agent.description
? "text-muted-foreground"
: "italic text-muted-foreground/50"
}`}
>
{agent.description || "No description"}
</div>
</div>
{/* Status — availability dimension only. Compact dot under md;
dot + label at md+. Always 3 colours. */}
<div className="flex items-center">
{isArchived ? (
<span className="text-xs text-muted-foreground"></span>
) : (
<>
<span className="md:hidden">
<AgentPresenceIndicator detail={presence} compact />
</span>
<span className="hidden md:inline-flex">
<AvailabilityCell presence={presence} />
</span>
</>
)}
</div>
{/* Last run — md+. Task icon + label + (running counts | reason | time).
Dedicated column so the user can scan "what just happened" without
merging it into the availability cell. */}
<div className="hidden items-center md:flex">
{isArchived ? (
<span className="text-xs text-muted-foreground"></span>
) : (
<LastRunCell presence={presence} />
)}
</div>
{/* Runtime — md+. Sans-font label fits ~25% more chars per pixel than
the previous mono treatment, so most hostnames no longer truncate. */}
<div className="hidden min-w-0 items-center gap-1.5 text-xs text-muted-foreground md:flex">
<RuntimeIcon className="h-3 w-3 shrink-0" />
<Tooltip>
<TooltipTrigger
render={<span className="min-w-0 truncate">{runtimeLabel}</span>}
/>
<TooltipContent>{runtimeLabel}</TooltipContent>
</Tooltip>
</div>
{/* Activity (7d) — lg+. The 7d sparkline is sliced off the 30d
workspace cache here (single source of truth shared with the
detail panel); no extra request. */}
<div className="hidden lg:block">
{isArchived ? (
<span className="text-xs text-muted-foreground/50"></span>
) : !activity ? (
<span
className="inline-block animate-pulse rounded bg-muted/60"
style={{ width: 64, height: 20 }}
/>
) : (
<Tooltip>
<TooltipTrigger
render={
<div className="inline-flex cursor-default items-center">
<Sparkline
buckets={summarizeActivityWindow(activity, 7).buckets}
width={64}
height={20}
/>
</div>
}
/>
<TooltipContent>
<ActivityTooltip activity={activity} />
</TooltipContent>
</Tooltip>
)}
</div>
{/* Runs (30d) — md+. tabular-nums + right-align keeps the digit column
visually clean across orders of magnitude. */}
<div className="hidden text-right font-mono text-xs tabular-nums text-muted-foreground md:block">
{runCount == null ? "—" : runCount.toLocaleString()}
</div>
{/* Actions — kebab dropdown, always visible. Empty cell is preserved
(the column has fixed width) when the user has no operable
actions, so column alignment stays stable across rows. */}
<div className="flex justify-end">
<AgentRowActions
agent={agent}
presence={presence}
canManage={canManage}
onDuplicate={onDuplicate}
/>
</div>
</AppLink>
);
}
// Availability cell — dot + label, colour from availabilityConfig. Three
// states only; the colour reflects "can the agent take work right now".
function AvailabilityCell({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
if (!presence) {
return <span className="inline-flex h-3 w-16 animate-pulse rounded bg-muted/60" />;
}
const av = availabilityConfig[presence.availability];
return (
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
</span>
);
}
// Last-run cell — task icon + label, with running counts only when active.
// No timestamp: it eats horizontal space and the relative age of a
// completed task isn't actionable in a scan. Hidden when idle so brand-
// new agents show "—" instead of "Idle" everywhere.
function LastRunCell({
presence,
}: {
presence: AgentPresenceDetail | null | undefined;
}) {
if (!presence) {
return <span className="inline-flex h-3 w-20 animate-pulse rounded bg-muted/60" />;
}
if (presence.lastTask === "idle") {
return <span className="text-xs text-muted-foreground/50"></span>;
}
const ts = taskStateConfig[presence.lastTask];
const isRunning = presence.lastTask === "running";
const counts =
isRunning && presence.queuedCount > 0
? `${presence.runningCount}/${presence.capacity} +${presence.queuedCount}q`
: isRunning
? `${presence.runningCount}/${presence.capacity}`
: null;
return (
<span className="inline-flex min-w-0 items-center gap-1 text-xs">
<ts.icon className={`h-3 w-3 shrink-0 ${ts.textClass}`} />
<span className={`shrink-0 ${ts.textClass}`}>{ts.label}</span>
{counts && (
<span className="truncate text-muted-foreground">{counts}</span>
)}
</span>
);
}
/**
* Tooltip body for the activity sparkline. Header = window label, body =
* the actual counts (rolled up from the same 7-day slice the bars
* render). Two short lines so the eye lands on the numbers immediately
* on hover.
*/
function ActivityTooltip({ activity }: { activity: AgentActivity }) {
const summary = summarizeActivityWindow(activity, 7);
const { totalRuns, totalFailed } = summary;
const { daysSinceCreated } = activity;
// Header: when the agent is younger than the rendered window, label by
// age so an empty sparkline reads as "new agent" not "broken agent".
const isPartial = daysSinceCreated < 7;
const headerText = isPartial
? `Created ${daysSinceCreated === 0 ? "today" : `${daysSinceCreated} day${daysSinceCreated === 1 ? "" : "s"} ago`}`
: "Last 7 days";
let bodyText: string;
if (totalRuns === 0) {
bodyText = "No activity";
} else {
const failedFragment =
totalFailed > 0
? ` · ${totalFailed} failed (${Math.round((totalFailed / totalRuns) * 100)}%)`
: "";
bodyText = `${totalRuns} run${totalRuns === 1 ? "" : "s"}${failedFragment}`;
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{headerText}
</span>
<span className="text-xs">{bodyText}</span>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import type { AgentPresenceDetail } from "@multica/core/agents";
import { availabilityConfig, taskStateConfig } from "../presence";
import { availabilityConfig, workloadConfig } from "../presence";
interface PresenceIndicatorProps {
// null/undefined = still loading. Caller passes the detail computed at
@@ -10,16 +10,16 @@ interface PresenceIndicatorProps {
// views). Keeping this as a prop avoids per-row hook subscriptions in
// long lists.
detail: AgentPresenceDetail | null | undefined;
// Compact = dot only, no label / no last-task chip. Used in dense rows.
// Compact = dot only, no label / no workload chip. Used in dense rows.
compact?: boolean;
}
/**
* Renders an agent's two-dimension presence: an availability dot + an
* optional last-task chip. The dot's colour reads only from the
* optional workload chip. The dot's colour reads only from the
* availability dimension (3 colours), so a runtime-healthy agent whose
* last task failed shows a green dot + a red "Failed" chip — the dot
* stops being sticky-red.
* last task failed shows a green dot — workload no longer carries
* historical state at all.
*
* Compact mode collapses to dot-only — used in dense surfaces where the
* full chip would crowd the row.
@@ -42,15 +42,24 @@ export function AgentPresenceIndicator({
}
const av = availabilityConfig[detail.availability];
const ts = taskStateConfig[detail.lastTask];
const isRunning = detail.lastTask === "running";
const showQueueBadge = isRunning && detail.queuedCount > 0;
const wl = workloadConfig[detail.workload];
const isWorking = detail.workload === "working";
const isQueued = detail.workload === "queued";
const showQueueBadge = isWorking && detail.queuedCount > 0;
// Queued's amber comes from workloadConfig as the *severe* tone — meant
// for "stuck on offline runtime", which is the dominant cause. But on a
// healthy runtime, queued is just a brief race between enqueue and the
// daemon's claim, and amber there reads as a warning that isn't there.
// Compose with availability: online ⇒ muted (transient), otherwise ⇒
// keep amber (genuine stuck signal).
const queuedTone =
detail.availability === "online" ? "text-muted-foreground" : wl.textClass;
if (compact) {
return (
<span
className="inline-flex items-center"
title={`${av.label}${detail.lastTask !== "idle" ? ` · ${ts.label}` : ""}`}
title={`${av.label}${detail.workload !== "idle" ? ` · ${wl.label}` : ""}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
</span>
@@ -65,24 +74,39 @@ export function AgentPresenceIndicator({
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
</span>
{/* Last task — separator + label, with running counts when active.
Hidden for `idle` to keep brand-new agents clean. */}
{detail.lastTask !== "idle" && (
<span className="inline-flex items-center gap-1">
<span className="text-xs text-muted-foreground">·</span>
<span className={`text-xs ${ts.textClass}`}>{ts.label}</span>
{isRunning && (
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{detail.runningCount} / {detail.capacity}
</span>
)}
{showQueueBadge && (
<span className="rounded-md bg-muted px-1 py-0 text-xs font-medium text-muted-foreground">
+{detail.queuedCount} queued
</span>
)}
{/* Workload — separator + label, with counts when working/queued.
All three workload states render here for symmetry: idle gets
its own "Idle" label so the difference between "no presence
data" (no chip at all) and "agent is idle" (explicit Idle chip)
is visible. */}
<span className="inline-flex items-center gap-1">
<span className="text-xs text-muted-foreground">·</span>
<span
className={`text-xs ${
isQueued ? queuedTone : wl.textClass
}`}
>
{wl.label}
</span>
)}
{isWorking && (
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{detail.runningCount} / {detail.capacity}
</span>
)}
{showQueueBadge && (
<span className="rounded-md bg-muted px-1 py-0 text-xs font-medium text-muted-foreground">
+{detail.queuedCount} queued
</span>
)}
{/* Queued (no running) — show the queued count directly, since
there's no running/capacity ratio to anchor on. Honestly
surfaces "stuck" on offline runtimes. */}
{isQueued && (
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{detail.queuedCount}
</span>
)}
</span>
</span>
);
}

View File

@@ -16,6 +16,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { AppLink } from "../../navigation";
import { HealthIcon } from "../../runtimes/components/shared";
import { availabilityConfig } from "../presence";
import { VisibilityBadge } from "./visibility-badge";
interface AgentProfileCardProps {
agentId: string;
@@ -81,6 +82,7 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-semibold">{agent.name}</p>
{!isArchived && <VisibilityBadge value={agent.visibility} compact />}
{isArchived && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Archived

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertCircle,
ArrowLeft,
@@ -10,10 +10,10 @@ import {
Search,
} from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import type { Agent, AgentRuntime, CreateAgentRequest } from "@multica/core/types";
import {
type AgentAvailability,
type LastTaskState,
agentRunCounts30dOptions,
summarizeActivityWindow,
useWorkspaceActivityMap,
@@ -22,6 +22,7 @@ import {
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { canAssignAgentToIssue } from "@multica/core/permissions";
import { useWorkspacePaths } from "@multica/core/paths";
import {
agentListOptions,
@@ -38,56 +39,27 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { Input } from "@multica/ui/components/ui/input";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { DataTable } from "@multica/ui/components/ui/data-table";
import { useNavigation } from "../../navigation";
import { PageHeader } from "../../layout/page-header";
import {
availabilityConfig,
availabilityOrder,
lastTaskOrder,
taskStateConfig,
} from "../presence";
import { availabilityConfig, availabilityOrder } from "../presence";
import { CreateAgentDialog } from "./create-agent-dialog";
import { AGENT_LIST_GRID, AgentListItem } from "./agent-list-item";
import { type AgentRow, createAgentColumns } from "./agent-columns";
// Filter axes layered top → bottom by frequency:
// Filter axes:
//
// View = which dataset are we looking at (Active vs Archived).
// Archived is low-frequency, so it is NOT a top-level
// segment — it is a ghost link in the toolbar.
// View = active vs archived dataset. Archived is low-frequency,
// accessed through a ghost link in the toolbar.
// Scope = ownership lens (All vs Mine). Layer-1 segment.
// Availability = "Can it take work?" — 3-state chip group.
// Last task = "What was the last thing it did?" — 5-state chip group.
//
// Availability and Last task are independent axes (Option B). Filter is
// the intersection: "online + last failed" is a meaningful combination
// (find broken-but-alive agents). Counts on each chip reflect "if I
// selected this chip on this axis (with the other axis's current
// selection), this many agents would match".
// Availability = "Can the agent take work right now?" — 3-state chip
// group (online / unstable / offline) sourced from
// AgentAvailability. The only chip filter we keep —
// the previous Workload axis was dropped because its
// "queued / failed / cancelled" buckets became
// meaningless once Failed left the workload model.
type View = "active" | "archived";
type Scope = "all" | "mine";
type AvailabilityFilter = "all" | AgentAvailability;
type LastTaskFilter = "all" | LastTaskState;
const AVAILABILITY_DESCRIPTION: Record<AgentAvailability, string> = {
online: "Runtime online — agent ready to take work",
unstable:
"Runtime just dropped (< 5 min) — queued work is paused, system is auto-retrying",
offline: "Runtime unreachable",
};
const LAST_TASK_DESCRIPTION: Record<LastTaskState, string> = {
running: "At least one task running or queued right now",
completed: "Most recent task completed successfully",
failed: "Most recent task failed — needs attention",
cancelled: "Most recent task was cancelled",
idle: "No task history yet",
};
type SortKey = "recent" | "name" | "runs" | "created";
const SORT_KEYS: SortKey[] = ["recent", "name", "runs", "created"];
@@ -131,7 +103,6 @@ export function AgentsPage() {
const [scope, setScope] = useState<Scope>("mine");
const [availabilityFilter, setAvailabilityFilter] =
useState<AvailabilityFilter>("all");
const [lastTaskFilter, setLastTaskFilter] = useState<LastTaskFilter>("all");
const [sort, setSort] = useState<SortKey>("recent");
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
@@ -143,9 +114,6 @@ export function AgentsPage() {
null,
);
const scrollRef = useRef<HTMLDivElement>(null);
const fadeStyle = useScrollFade(scrollRef);
const runtimesById = useMemo(() => {
const m = new Map<string, AgentRuntime>();
for (const r of runtimes) m.set(r.id, r);
@@ -176,102 +144,82 @@ export function AgentsPage() {
[agents, view],
);
// Layer 1b — ownership scope. Counts shown on the segment are
// computed against the inView set so the numbers always reflect
// Layer 1b — visibility. Personal (visibility=private) agents owned by
// someone else are hidden from regular members; workspace owners/admins
// still see everything. Mirrors the assign-to-issue gate so the list
// only ever shows agents the user could actually act on. Backend keeps
// returning all agents, so admin tools (and the API itself) are
// unaffected — this is a UI-only filter.
const visibleInView = useMemo(() => {
return inView.filter((a) =>
canAssignAgentToIssue(a, {
userId: currentUser?.id ?? null,
role: myRole,
}).allowed,
);
}, [inView, currentUser?.id, myRole]);
// Layer 1c — ownership scope. Counts shown on the segment are
// computed against the visibleInView set so the numbers always reflect
// "what would I see if I clicked this".
const scopeCounts = useMemo(() => {
let mine = 0;
if (currentUser) {
for (const a of inView) {
for (const a of visibleInView) {
if (a.owner_id === currentUser.id) mine += 1;
}
}
return { all: inView.length, mine };
}, [inView, currentUser]);
return { all: visibleInView.length, mine };
}, [visibleInView, currentUser]);
const inScope = useMemo(() => {
if (scope === "all" || !currentUser) return inView;
return inView.filter((a) => a.owner_id === currentUser.id);
}, [inView, scope, currentUser]);
// Archived view ignores Mine / All — its toolbar has no scope
// segment, so silently filtering by `scope` would hide other
// people's archived agents without any UI to explain why.
if (view === "archived") return visibleInView;
if (scope === "all" || !currentUser) return visibleInView;
return visibleInView.filter((a) => a.owner_id === currentUser.id);
}, [visibleInView, scope, currentUser, view]);
// Layer 2 — chip counts on each axis. Counts cross-filter against the
// OTHER axis so the displayed number is "if I clicked this chip with
// the other axis as-is, this many agents would match". Stable mental
// model: numbers don't dance unless the user actually changes scope.
// Final cut — availability chip + search.
const filteredAgents = useMemo(() => {
const q = search.trim().toLowerCase();
return inScope.filter((a) => {
// Availability chip filter only applies to the Active view —
// archived agents have no presence to match against.
if (view === "active" && availabilityFilter !== "all") {
const detail = presenceMap.get(a.id);
if (detail?.availability !== availabilityFilter) return false;
}
if (q) {
if (
!a.name.toLowerCase().includes(q) &&
!(a.description ?? "").toLowerCase().includes(q)
) {
return false;
}
}
return true;
});
}, [inScope, view, availabilityFilter, presenceMap, search]);
// Per-availability counts for the chip badges. Computed against
// `inScope` (ignoring the availability filter itself) so the numbers
// reflect "if I clicked this chip, this many agents would match"
// rather than collapsing to 0 for the unselected chips.
const availabilityCounts = useMemo(() => {
const counts: Record<AgentAvailability, number> = {
online: 0,
unstable: 0,
offline: 0,
};
let total = 0;
for (const a of inScope) {
const detail = presenceMap.get(a.id);
if (!detail) continue;
if (lastTaskFilter !== "all" && detail.lastTask !== lastTaskFilter) {
continue;
}
counts[detail.availability] += 1;
total += 1;
}
return { counts, total };
}, [inScope, presenceMap, lastTaskFilter]);
const lastTaskCounts = useMemo(() => {
const counts: Record<LastTaskState, number> = {
running: 0,
completed: 0,
failed: 0,
cancelled: 0,
idle: 0,
};
let total = 0;
for (const a of inScope) {
const detail = presenceMap.get(a.id);
if (!detail) continue;
if (
availabilityFilter !== "all" &&
detail.availability !== availabilityFilter
) {
continue;
}
counts[detail.lastTask] += 1;
total += 1;
}
return { counts, total };
}, [inScope, presenceMap, availabilityFilter]);
// Final cut — apply both axes + search.
const filteredAgents = useMemo(() => {
const q = search.trim().toLowerCase();
return inScope.filter((a) => {
// Filter chips only apply to the Active view; Archived hides the
// chip rows entirely (presence is undefined for archived agents).
if (view === "active") {
const detail = presenceMap.get(a.id);
if (
availabilityFilter !== "all" &&
detail?.availability !== availabilityFilter
) {
return false;
}
if (
lastTaskFilter !== "all" &&
detail?.lastTask !== lastTaskFilter
) {
return false;
}
}
if (q) {
if (
!a.name.toLowerCase().includes(q) &&
!(a.description ?? "").toLowerCase().includes(q)
)
return false;
}
return true;
});
}, [inScope, availabilityFilter, lastTaskFilter, view, search, presenceMap]);
return counts;
}, [inScope, presenceMap]);
const sortedAgents = useMemo(() => {
const xs = [...filteredAgents];
@@ -347,16 +295,67 @@ export function AgentsPage() {
// Surfaced softly; the agent itself is fine.
}
}
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
setShowCreate(false);
setDuplicateTemplate(null);
navigation.push(paths.agentDetail(agent.id));
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
};
const handleDuplicate = (agent: Agent) => {
const handleDuplicate = useCallback((agent: Agent) => {
setDuplicateTemplate(agent);
setShowCreate(true);
};
}, []);
// Assemble per-row data once per render — agent + runtime + presence +
// activity + role flags. The columns reach into `row.original` and never
// pull their own queries, which keeps each cell a pure function.
const agentRows = useMemo<AgentRow[]>(() => {
return sortedAgents.map((agent) => {
const isOwner =
!!currentUser?.id && agent.owner_id === currentUser.id;
const canManage = isWorkspaceAdmin || isOwner;
const ownerIdToShow =
scope === "all" &&
agent.owner_id &&
agent.owner_id !== currentUser?.id
? agent.owner_id
: null;
return {
agent,
runtime: runtimesById.get(agent.runtime_id) ?? null,
presence: presenceMap.get(agent.id) ?? null,
activity: activityMap.get(agent.id) ?? null,
runCount: runCountsById.get(agent.id) ?? 0,
ownerIdToShow,
isOwnedByMe: isOwner,
canManage,
};
});
}, [
sortedAgents,
currentUser,
isWorkspaceAdmin,
scope,
runtimesById,
presenceMap,
activityMap,
runCountsById,
]);
const columns = useMemo(
() => createAgentColumns({ onDuplicate: handleDuplicate }),
[handleDuplicate],
);
const table = useReactTable({
data: agentRows,
columns,
getCoreRowModel: getCoreRowModel(),
enableColumnResizing: true,
// Pin the kebab column right so it stays accessible during horizontal
// scroll — matches the pattern in Linear / Notion / GitHub.
initialState: { columnPinning: { right: ["actions"] } },
});
// ---- Loading ----
if (isLoading) {
@@ -439,21 +438,17 @@ export function AgentsPage() {
setSort={setSort}
search={search}
setSearch={setSearch}
/>
<PresenceFilterRows
availabilityFilter={availabilityFilter}
setAvailabilityFilter={setAvailabilityFilter}
availabilityCounts={availabilityCounts.counts}
availabilityTotal={availabilityCounts.total}
lastTaskFilter={lastTaskFilter}
setLastTaskFilter={setLastTaskFilter}
lastTaskCounts={lastTaskCounts.counts}
lastTaskTotal={lastTaskCounts.total}
visibleCount={sortedAgents.length}
totalCount={inScope.length}
archivedCount={archivedCount}
onShowArchived={() => setView("archived")}
/>
<AvailabilityFilterRow
value={availabilityFilter}
onChange={setAvailabilityFilter}
counts={availabilityCounts}
totalCount={inScope.length}
/>
</>
) : (
<ArchivedToolbarRow
@@ -465,76 +460,14 @@ export function AgentsPage() {
)}
{sortedAgents.length === 0 ? (
<NoMatches
view={view}
search={search}
hasFilter={
availabilityFilter !== "all" || lastTaskFilter !== "all"
}
scope={scope}
/>
<NoMatches view={view} search={search} scope={scope} />
) : (
<div
ref={scrollRef}
style={fadeStyle}
className="flex-1 min-h-0 overflow-y-auto"
>
{/*
Layout strategy — CSS Grid + `max-content` on Status, ratio
fr's elsewhere. The Status column shrinks to fit when no
agent is in a high-load Working state, and only widens
when the data demands it; the freed space flows into the
Agent (1.6fr) primary column. See AGENT_LIST_GRID for the
full breakpoint ladder. Sticky header reuses the same grid
template so column edges align with rows pixel-for-pixel.
*/}
<div
role="row"
className={`${AGENT_LIST_GRID} sticky top-0 z-10 border-b bg-muted/30 px-4 py-2 text-xs font-medium uppercase tracking-wider text-muted-foreground backdrop-blur`}
>
{/* Avatar leading slot — empty header cell so the "Agent"
label below aligns with the row's name text, not the
avatar's left edge. */}
<span aria-hidden />
<span>Agent</span>
<span>Status</span>
<span className="hidden md:block">Last run</span>
<span className="hidden md:block">Runtime</span>
<span className="hidden lg:block">Activity (7d)</span>
<span className="hidden text-right md:block">Runs</span>
{/* Operations column header — kept silent; the kebab
cell speaks for itself. */}
<span aria-label="Actions" />
</div>
{sortedAgents.map((agent) => {
const isOwner =
!!currentUser?.id && agent.owner_id === currentUser.id;
const canManage = isWorkspaceAdmin || isOwner;
// Inline owner avatar only in All scope on a teammate's
// agent — Mine scope means owner is always you, so a
// self-avatar everywhere would be visual noise.
const ownerIdToShow =
scope === "all" &&
agent.owner_id &&
agent.owner_id !== currentUser?.id
? agent.owner_id
: null;
return (
<AgentListItem
key={agent.id}
agent={agent}
runtime={runtimesById.get(agent.runtime_id) ?? null}
presence={presenceMap.get(agent.id) ?? null}
activity={activityMap.get(agent.id) ?? null}
runCount={runCountsById.get(agent.id) ?? 0}
ownerIdToShow={ownerIdToShow}
canManage={canManage}
onDuplicate={handleDuplicate}
href={paths.agentDetail(agent.id)}
/>
);
})}
</div>
<DataTable
table={table}
onRowClick={(row) =>
navigation.push(paths.agentDetail(row.original.agent.id))
}
/>
)}
</div>
)}
@@ -616,6 +549,10 @@ function ActiveToolbarRow({
setSort,
search,
setSearch,
visibleCount,
totalCount,
archivedCount,
onShowArchived,
}: {
scope: Scope;
setScope: (v: Scope) => void;
@@ -624,13 +561,18 @@ function ActiveToolbarRow({
setSort: (v: SortKey) => void;
search: string;
setSearch: (v: string) => void;
visibleCount: number;
totalCount: number;
archivedCount: number;
onShowArchived: () => void;
}) {
// Layout follows Skills: [Search] [Mine|All] [Sort ▼]
// Search and the scope segment cluster on the left (Skills puts its
// filter buttons immediately after the search the same way). Sort
// gets pushed to the far right via ml-auto.
// Layout: [Search] [Mine|All] ......... [Show archived] [N of M] [Sort ▼]
// Filter chips were removed (status / workload chips on a small team
// gain less than they cost), so the toolbar collapses to a single row.
// Visible/total count and the archived link inherit their old position
// from the deleted PresenceFilterRows.
return (
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -641,7 +583,19 @@ function ActiveToolbarRow({
/>
</div>
<ScopeSegment scope={scope} setScope={setScope} counts={scopeCounts} />
<div className="ml-auto">
<div className="ml-auto flex items-center gap-3">
{archivedCount > 0 && (
<button
type="button"
onClick={onShowArchived}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Show archived ({archivedCount})
</button>
)}
<span className="font-mono text-xs tabular-nums text-muted-foreground/70">
{visibleCount} of {totalCount}
</span>
<SortDropdown sort={sort} setSort={setSort} />
</div>
</div>
@@ -747,161 +701,76 @@ function SortDropdown({
}
// ---------------------------------------------------------------------------
// Active view — Layer 2: two independent filter axes (availability + last
// task) + visible/total count. The right edge hosts the low-frequency
// "Show archived" link, kept out of Layer 1 so the primary toolbar stays
// uncluttered.
//
// Two rows because cramming both axes into a single row makes the chip
// labels feel ambiguous ("Online" and "Failed" side by side reads as a
// single stack of facets, but they're on different axes). Two rows with
// a leading label make the axis split obvious.
// Availability chip row — All / Online / Unstable / Offline. Only shown
// in the Active view; archived agents have no presence.
// ---------------------------------------------------------------------------
function PresenceFilterRows({
availabilityFilter,
setAvailabilityFilter,
availabilityCounts,
availabilityTotal,
lastTaskFilter,
setLastTaskFilter,
lastTaskCounts,
lastTaskTotal,
visibleCount,
function AvailabilityFilterRow({
value,
onChange,
counts,
totalCount,
archivedCount,
onShowArchived,
}: {
availabilityFilter: AvailabilityFilter;
setAvailabilityFilter: (v: AvailabilityFilter) => void;
availabilityCounts: Record<AgentAvailability, number>;
availabilityTotal: number;
lastTaskFilter: LastTaskFilter;
setLastTaskFilter: (v: LastTaskFilter) => void;
lastTaskCounts: Record<LastTaskState, number>;
lastTaskTotal: number;
visibleCount: number;
value: AvailabilityFilter;
onChange: (v: AvailabilityFilter) => void;
counts: Record<AgentAvailability, number>;
totalCount: number;
archivedCount: number;
onShowArchived: () => void;
}) {
return (
<div className="flex shrink-0 flex-col gap-1.5 border-b px-4 py-2.5">
{/* Row 1: Availability — 3 chips. */}
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-xs text-muted-foreground">
Status
</span>
<PresenceChip
active={availabilityFilter === "all"}
onClick={() => setAvailabilityFilter("all")}
label="All"
count={availabilityTotal}
description="No availability filter"
/>
{availabilityOrder.map((a) => {
const cfg = availabilityConfig[a];
return (
<PresenceChip
key={a}
active={availabilityFilter === a}
onClick={() => setAvailabilityFilter(a)}
label={cfg.label}
count={availabilityCounts[a]}
dotClass={cfg.dotClass}
description={AVAILABILITY_DESCRIPTION[a]}
/>
);
})}
<div className="ml-auto flex items-center gap-3">
{archivedCount > 0 && (
<button
type="button"
onClick={onShowArchived}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Show archived ({archivedCount})
</button>
)}
<span className="font-mono text-xs tabular-nums text-muted-foreground/70">
{visibleCount} of {totalCount}
</span>
</div>
</div>
{/* Row 2: Last task — 5 chips. */}
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-xs text-muted-foreground">
Last run
</span>
<PresenceChip
active={lastTaskFilter === "all"}
onClick={() => setLastTaskFilter("all")}
label="All"
count={lastTaskTotal}
description="No last-run filter"
/>
{lastTaskOrder.map((t) => {
const cfg = taskStateConfig[t];
return (
<PresenceChip
key={t}
active={lastTaskFilter === t}
onClick={() => setLastTaskFilter(t)}
label={cfg.label}
count={lastTaskCounts[t]}
description={LAST_TASK_DESCRIPTION[t]}
/>
);
})}
</div>
<div className="flex h-11 shrink-0 items-center gap-2 border-b px-4">
<AvailabilityChip
active={value === "all"}
onClick={() => onChange("all")}
label="All"
count={totalCount}
/>
{availabilityOrder.map((a) => {
const cfg = availabilityConfig[a];
return (
<AvailabilityChip
key={a}
active={value === a}
onClick={() => onChange(a)}
label={cfg.label}
count={counts[a]}
dotClass={cfg.dotClass}
/>
);
})}
</div>
);
}
// Same Button + Tooltip pattern Skills uses for its scope filters. Selected
// state mirrors Skills' `bg-accent text-accent-foreground hover:bg-accent/80`,
// so any future global tweak to that token cascades here for free.
function PresenceChip({
function AvailabilityChip({
active,
onClick,
label,
count,
dotClass,
description,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
dotClass?: string;
description: string;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
onClick={onClick}
className={
active
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground"
}
>
{dotClass && (
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
)}
<span>{label}</span>
<span className="font-mono tabular-nums text-muted-foreground/70">
{count}
</span>
</Button>
}
/>
<TooltipContent side="top">{description}</TooltipContent>
</Tooltip>
<Button
variant="outline"
size="sm"
onClick={onClick}
className={
active
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground"
}
>
{dotClass && <span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />}
<span>{label}</span>
<span className="font-mono tabular-nums text-muted-foreground/70">
{count}
</span>
</Button>
);
}
@@ -969,16 +838,17 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
function NoMatches({
view,
search,
hasFilter: filterActive,
scope,
}: {
view: View;
search: string;
hasFilter: boolean;
scope: Scope;
}) {
const hasSearch = search.length > 0;
const hasFilter = filterActive || scope === "mine";
// "mine" is the only remaining narrowing dimension after chip filters
// were dropped — keep the wording aware of it so an empty Mine view
// doesn't suggest the workspace itself is empty.
const hasFilter = scope === "mine";
let body: string;
if (view === "archived") {

View File

@@ -0,0 +1,19 @@
// Soft warn at 90 % of the cap, hard error past it. Shared between the
// description editor (modal) and the create-agent dialog so both surfaces
// read the same way. Renders a single inline line so it can sit under any
// textarea / input without disturbing surrounding spacing.
export function CharCounter({ length, max }: { length: number; max: number }) {
const over = length > max;
const near = !over && length >= Math.floor(max * 0.9);
const tone = over
? "text-destructive"
: near
? "text-warning"
: "text-muted-foreground";
return (
<div className={`text-right text-xs tabular-nums ${tone}`}>
{length} / {max}
{over && ` · ${length - max} over limit`}
</div>
);
}

View File

@@ -29,6 +29,12 @@ import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
import {
AGENT_DESCRIPTION_MAX_LENGTH,
VISIBILITY_DESCRIPTION,
VISIBILITY_LABEL,
} from "@multica/core/agents";
import { CharCounter } from "./char-counter";
type RuntimeFilter = "mine" | "all";
@@ -175,8 +181,15 @@ export function CreateAgentDialog({
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this agent do?"
maxLength={AGENT_DESCRIPTION_MAX_LENGTH}
className="mt-1"
/>
<div className="mt-1">
<CharCounter
length={[...description].length}
max={AGENT_DESCRIPTION_MAX_LENGTH}
/>
</div>
</div>
<div>
@@ -193,8 +206,10 @@ export function CreateAgentDialog({
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">All members can assign</div>
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.workspace}
</div>
</div>
</button>
<button
@@ -208,8 +223,10 @@ export function CreateAgentDialog({
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">Only you can assign</div>
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.private}
</div>
</div>
</button>
</div>

View File

@@ -11,14 +11,25 @@ const MAX = 50;
export function ConcurrencyPicker({
value,
canEdit = true,
onChange,
}: {
value: number;
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (next: number) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(String(value));
if (!canEdit) {
return (
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{value}
</span>
);
}
// Reset draft from authoritative value whenever the popover (re-)opens or
// the prop changes from elsewhere — protects against stale draft state if
// the user closes mid-edit and reopens later.

View File

@@ -26,11 +26,14 @@ export function ModelPicker({
runtimeId,
runtimeOnline,
value,
canEdit = true,
onChange,
}: {
runtimeId: string | null;
runtimeOnline: boolean;
value: string;
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (next: string) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
@@ -41,13 +44,12 @@ export function ModelPicker({
);
const supported = modelsQuery.data?.supported ?? true;
// Memoise the model list so every downstream useMemo gets a stable
// reference `?? []` would mint a fresh array on every render and
// invalidate filters / defaultModel needlessly.
// reference; `?? []` would mint a fresh array on every render and
// invalidate filters needlessly.
const models = useMemo(
() => modelsQuery.data?.models ?? [],
[modelsQuery.data],
);
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
const filtered = useMemo(() => {
const s = search.trim().toLowerCase();
@@ -78,11 +80,20 @@ export function ModelPicker({
);
}
const triggerLabel =
value ||
(defaultModel ? `Default — ${defaultModel.label}` : "Default");
const triggerLabel = value || "Default";
const triggerTitle = `Model · ${triggerLabel}`;
if (!canEdit) {
return (
<span
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
title={triggerTitle}
>
{triggerLabel}
</span>
);
}
return (
<PropertyPicker
open={open}

View File

@@ -24,12 +24,15 @@ export function RuntimePicker({
runtimes,
members,
currentUserId,
canEdit = true,
onChange,
}: {
value: string;
runtimes: AgentRuntime[];
members: MemberWithUser[];
currentUserId: string | null;
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (runtimeId: string) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
@@ -37,6 +40,25 @@ export function RuntimePicker({
const selected = runtimes.find((r) => r.id === value) ?? null;
const Icon = selected?.runtime_mode === "cloud" ? Cloud : Monitor;
if (!canEdit) {
const isOnline = selected?.status === "online";
return (
<span className="inline-flex min-w-0 items-center gap-1.5 px-1.5 py-0.5 text-xs text-muted-foreground">
<Icon className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate font-mono">
{selected?.name ?? "No runtime"}
</span>
{selected && (
<span
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
isOnline ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
)}
</span>
);
}
// The chip shows only the runtime name. `runtime.name` already comes back
// from the back-end pre-formatted as e.g. "Claude (host.local)", so we
// deliberately do NOT append `device_info` to the tooltip — that string

View File

@@ -17,7 +17,14 @@ import { SkillAddDialog } from "../skill-add-dialog";
* Hidden when there's nothing left to attach so we don't dangle a chip
* that opens an empty dialog.
*/
export function SkillAttach({ agent }: { agent: Agent }) {
export function SkillAttach({
agent,
canEdit = true,
}: {
agent: Agent;
/** When false, hide the attach trigger entirely. */
canEdit?: boolean;
}) {
const wsId = useWorkspaceId();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [open, setOpen] = useState(false);
@@ -27,7 +34,7 @@ export function SkillAttach({ agent }: { agent: Agent }) {
(s) => !agentSkillIds.has(s.id),
).length;
if (availableCount === 0) return null;
if (!canEdit || availableCount === 0) return null;
return (
<>

View File

@@ -2,27 +2,38 @@
import { useState } from "react";
import { Globe, Lock } from "lucide-react";
import {
VISIBILITY_DESCRIPTION,
VISIBILITY_LABEL,
VISIBILITY_TOOLTIP,
} from "@multica/core/agents";
import type { AgentVisibility } from "@multica/core/types";
import {
PickerItem,
PropertyPicker,
} from "../../../issues/components/pickers";
import { VisibilityBadge } from "../visibility-badge";
import { CHIP_CLASS } from "./chip";
export function VisibilityPicker({
value,
canEdit = true,
onChange,
}: {
value: AgentVisibility;
/** When false, render a read-only `<VisibilityBadge>` and skip the popover. */
canEdit?: boolean;
onChange: (next: AgentVisibility) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
if (!canEdit) {
return <VisibilityBadge value={value} />;
}
const Icon = value === "private" ? Lock : Globe;
const label = value === "private" ? "Private" : "Workspace";
const tooltip =
value === "private"
? "Visibility · Private — only you can assign"
: "Visibility · Workspace — all members can assign";
const label = VISIBILITY_LABEL[value];
const tooltip = `Visibility · ${VISIBILITY_TOOLTIP[value]}`;
const select = async (next: AgentVisibility) => {
setOpen(false);
@@ -52,9 +63,9 @@ export function VisibilityPicker({
>
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Workspace</div>
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
<div className="text-xs text-muted-foreground">
All members can assign
{VISIBILITY_DESCRIPTION.workspace}
</div>
</div>
</PickerItem>
@@ -64,9 +75,9 @@ export function VisibilityPicker({
>
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Private</div>
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
<div className="text-xs text-muted-foreground">
Only you can assign
{VISIBILITY_DESCRIPTION.private}
</div>
</div>
</PickerItem>

View File

@@ -42,7 +42,6 @@ export function ModelDropdown({
const supported = modelsQuery.data?.supported ?? true;
const models = modelsQuery.data?.models ?? [];
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
const grouped = useMemo(() => groupByProvider(models), [models]);
// When the selected runtime reports it doesn't support per-agent
@@ -86,9 +85,7 @@ export function ModelDropdown({
(disabled
? "Select a runtime first"
: runtimeOnline
? defaultModel
? `Default — ${defaultModel.label}`
: "Default (provider)"
? "Default (provider)"
: "Runtime offline — enter manually");
if (!supported && !modelsQuery.isLoading) {

View File

@@ -1,14 +1,16 @@
"use client";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
ArrowUpRight,
CircleHelp,
Hash,
MessageSquare,
Workflow,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
Tooltip,
TooltipContent,
@@ -28,6 +30,7 @@ import {
summarizeActivityWindow,
useWorkspaceActivityMap,
} from "@multica/core/agents";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { issueDetailOptions } from "@multica/core/issues/queries";
@@ -35,11 +38,16 @@ import { timeAgo } from "@multica/core/utils";
import { AppLink } from "../../../navigation";
import { TranscriptButton } from "../../../common/task-transcript";
import { taskStatusConfig } from "../../config";
import { failureReasonLabel } from "../../presence";
import { failureReasonLabel } from "./task-failure";
import { Sparkline } from "../sparkline";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const RECENT_LIMIT = 5;
// Recent work pagination: small initial cohort to keep the section
// scannable, then "Show more" reveals 20 at a time. Tasks are already
// fully cached client-side (one listAgentTasks for the whole agent), so
// "more" is a pure state flip — zero extra fetches.
const RECENT_INITIAL = 5;
const RECENT_PAGE = 20;
interface ActivityTabProps {
agent: Agent;
@@ -66,10 +74,19 @@ export function ActivityTab({ agent }: ActivityTabProps) {
const { byAgent: activityMap } = useWorkspaceActivityMap(wsId);
const activity = activityMap.get(agent.id);
const [recentDisplayLimit, setRecentDisplayLimit] = useState(RECENT_INITIAL);
// Chat tasks are intentionally hidden across every Agent-scoped surface
// (list / detail / activity). They have their own UI in the chat
// experience; mixing them in here muddies "what is this agent doing
// for the team" with "what is this agent doing in private chat".
const isWorkflowTask = (t: AgentTask) => !t.chat_session_id;
const activeTasks = useMemo(() => {
return snapshot.filter(
(t) =>
t.agent_id === agent.id &&
isWorkflowTask(t) &&
(t.status === "running" ||
t.status === "queued" ||
t.status === "dispatched"),
@@ -78,11 +95,12 @@ export function ActivityTab({ agent }: ActivityTabProps) {
// Most recent terminal tasks. Includes cancelled — users searching
// "what just happened" want to see cancellations alongside completions
// and failures.
const recentTasks = useMemo(() => {
// and failures. Chat sessions filtered out for the same reason as above.
const recentTasksAll = useMemo(() => {
return [...agentTasks]
.filter(
(t) =>
isWorkflowTask(t) &&
!!t.completed_at &&
(t.status === "completed" ||
t.status === "failed" ||
@@ -92,10 +110,15 @@ export function ActivityTab({ agent }: ActivityTabProps) {
(a, b) =>
new Date(b.completed_at!).getTime() -
new Date(a.completed_at!).getTime(),
)
.slice(0, RECENT_LIMIT);
);
}, [agentTasks]);
const recentTasks = useMemo(
() => recentTasksAll.slice(0, recentDisplayLimit),
[recentTasksAll, recentDisplayLimit],
);
const hasMoreRecent = recentTasksAll.length > recentTasks.length;
const avgDurationMs = useMemo(
() => deriveAvgDurationLast30d(agentTasks, Date.now()),
[agentTasks],
@@ -133,6 +156,11 @@ export function ActivityTab({ agent }: ActivityTabProps) {
<Last30dSection activity={activity} avgDurationMs={avgDurationMs} />
<RecentWorkSection
tasks={recentTasks}
totalCount={recentTasksAll.length}
hasMore={hasMoreRecent}
onShowMore={() =>
setRecentDisplayLimit((n) => n + RECENT_PAGE)
}
issueMap={issueMap}
agent={agent}
/>
@@ -244,31 +272,52 @@ function Last30dSection({
function RecentWorkSection({
tasks,
totalCount,
hasMore,
onShowMore,
issueMap,
agent,
}: {
tasks: AgentTask[];
totalCount: number;
hasMore: boolean;
onShowMore: () => void;
issueMap: Map<string, Issue>;
agent: Agent;
}) {
// Subtitle phrasing: "5 of 47" once we know the total is bigger than
// what we're rendering, otherwise "5 latest". Total comes from
// recentTasksAll (already filtered for chat / terminals) so it
// accurately reflects what would appear if the user kept clicking
// "Show more" — not the raw on-the-wire row count.
const subtitle =
tasks.length === 0
? "Nothing finished yet"
: totalCount > tasks.length
? `${tasks.length} of ${totalCount}`
: `${tasks.length} latest`;
return (
<Section
title="Recent work"
subtitle={
tasks.length === 0
? "Nothing finished yet"
: `${tasks.length} latest`
}
>
<Section title="Recent work" subtitle={subtitle}>
{tasks.length === 0 ? (
<EmptyText>This agent hasn&apos;t completed anything yet.</EmptyText>
) : (
<TaskList
tasks={tasks}
issueMap={issueMap}
timeMode="completed"
agent={agent}
/>
<>
<TaskList
tasks={tasks}
issueMap={issueMap}
timeMode="completed"
agent={agent}
/>
{hasMore && (
<button
type="button"
onClick={onShowMore}
className="mt-2 self-start rounded text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Show more
</button>
)}
</>
)}
</Section>
);
@@ -312,6 +361,7 @@ function TaskRow({
agent: Agent;
}) {
const paths = useWorkspacePaths();
const [cancelling, setCancelling] = useState(false);
const cfg = taskStatusConfig[task.status] ?? taskStatusConfig.queued!;
const Icon = cfg.icon;
const hasIssue = task.issue_id !== "";
@@ -320,13 +370,47 @@ function TaskRow({
// Queued tasks have no messages yet — hiding the transcript button avoids
// a guaranteed "No execution data recorded." dialog open.
const showTranscript = task.status !== "queued";
// Cancel only makes sense for the three active states. Terminal rows
// (completed / failed / cancelled) hide the button entirely.
const showCancel =
timeMode === "active" &&
(task.status === "queued" ||
task.status === "dispatched" ||
task.status === "running");
const handleCancel = async () => {
if (cancelling) return;
setCancelling(true);
try {
await api.cancelTaskById(task.id);
// No manual invalidate needed — the task:cancelled WS event flows
// through useRealtimeSync's `task:` prefix path which already
// invalidates snapshot + per-agent + per-issue task lists.
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
setCancelling(false);
}
};
// Terminal states never use active wording. The server links the new
// issue back to a quick-create task on completion (so most successful
// rows transition to kind=direct on next refetch), but rows whose link
// write failed — or whose agent never created the issue at all — would
// otherwise sit on "Creating issue" forever.
const isTerminalStatus =
task.status === "completed" ||
task.status === "failed" ||
task.status === "cancelled";
const sourceFallback = !hasIssue
? task.chat_session_id
? "Chat session"
: task.autopilot_run_id
? "Autopilot run"
: "Untracked"
? task.kind === "quick_create"
? isTerminalStatus
? "Quick create"
: "Creating issue"
: task.chat_session_id
? "Chat session"
: task.autopilot_run_id
? "Autopilot run"
: "Untracked"
: null;
// Origin marker — issue / chat / autopilot / untracked. The issue
@@ -375,7 +459,7 @@ function TaskRow({
}
const rowClass = `group flex items-center gap-3 rounded-md border px-3 py-2.5 ${
isRunning ? "border-success/40 bg-success/5" : ""
isRunning ? "border-brand/40 bg-brand/5" : ""
}`;
return (
@@ -396,12 +480,40 @@ function TaskRow({
{issue.identifier}
</span>
)}
<span className="truncate text-sm">
{issue?.title ??
(hasIssue
? `Issue ${task.issue_id.slice(0, 8)}`
: (sourceFallback ?? "Untracked"))}
</span>
{task.trigger_summary ? (
// Hover surfaces "why this task ran" — the snapshot lets the
// agent-side row stay anchored on issue.title (the
// identification axis here) while still letting the user
// dwell to see the trigger context. Same pattern as
// GitHub Actions surfacing the commit message on hover.
<Tooltip>
<TooltipTrigger
render={
<span className="truncate text-sm">
{issue?.title ??
(hasIssue
? `Issue ${task.issue_id.slice(0, 8)}`
: (sourceFallback ?? "Untracked"))}
</span>
}
/>
<TooltipContent className="max-w-md">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">
Triggered by
</div>
<div className="mt-0.5 whitespace-pre-wrap text-xs">
{task.trigger_summary}
</div>
</TooltipContent>
</Tooltip>
) : (
<span className="truncate text-sm">
{issue?.title ??
(hasIssue
? `Issue ${task.issue_id.slice(0, 8)}`
: (sourceFallback ?? "Untracked"))}
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span>{timeText}</span>
@@ -445,6 +557,26 @@ function TaskRow({
title="View transcript"
/>
)}
{showCancel && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={handleCancel}
disabled={cancelling}
aria-label="Cancel task"
/>
}
className="flex items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
>
<X className="h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>
{cancelling ? "Cancelling…" : "Cancel task"}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);

View File

@@ -0,0 +1,16 @@
import type { TaskFailureReason } from "@multica/core/types";
// Human-readable copy for the back-end task failure reason enum. Surfaced
// in the agent detail Recent Work tab when a task ended in failure — the
// only place the front-end exposes failure_reason directly to the user.
//
// Lives next to the consuming tab (rather than in agents/presence) because
// failed tasks no longer have a top-level workload state; failure context
// is purely a detail-page concern now.
export const failureReasonLabel: Record<TaskFailureReason, string> = {
agent_error: "Agent execution error",
timeout: "Task timed out",
runtime_offline: "Daemon offline",
runtime_recovery: "Daemon restarted",
manual: "Cancelled by user",
};

View File

@@ -0,0 +1,49 @@
"use client";
import { Globe, Lock } from "lucide-react";
import {
VISIBILITY_LABEL,
VISIBILITY_TOOLTIP,
} from "@multica/core/agents";
import type { AgentVisibility } from "@multica/core/types";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
/**
* Read-only visibility badge — used wherever a user should *see* an agent's
* visibility (Personal / Workspace) without being able to change it. Replaces
* the interactive `<VisibilityPicker>` for non-managers on the detail page,
* and is also the canonical badge for hover cards and list rows.
*
* `compact` drops the text label and shows just the icon — for tight spaces
* like the agent table where the column header already labels the field.
*/
export function VisibilityBadge({
value,
compact = false,
className = "",
}: {
value: AgentVisibility;
compact?: boolean;
className?: string;
}) {
const Icon = value === "private" ? Lock : Globe;
const label = VISIBILITY_LABEL[value];
const tooltip = VISIBILITY_TOOLTIP[value];
return (
<Tooltip>
<TooltipTrigger
render={
<span
className={`inline-flex items-center gap-1 text-xs text-muted-foreground ${className}`}
aria-label={tooltip}
>
<Icon className="h-3 w-3 shrink-0" />
{!compact && <span className="truncate">{label}</span>}
</span>
}
/>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}

View File

@@ -9,7 +9,7 @@ import {
export const taskStatusConfig: Record<string, { label: string; icon: typeof CheckCircle2; color: string }> = {
queued: { label: "Queued", icon: Clock, color: "text-muted-foreground" },
dispatched: { label: "Dispatched", icon: Play, color: "text-info" },
running: { label: "Running", icon: Loader2, color: "text-success" },
running: { label: "Running", icon: Loader2, color: "text-brand" },
completed: { label: "Completed", icon: CheckCircle2, color: "text-success" },
failed: { label: "Failed", icon: XCircle, color: "text-destructive" },
cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" },

View File

@@ -1,21 +1,18 @@
import {
AlertCircle,
CheckCircle2,
CircleDot,
CircleSlash,
Clock,
Loader2,
PauseCircle,
PlugZap,
XCircle,
type LucideIcon,
} from "lucide-react";
import type { AgentAvailability, LastTaskState } from "@multica/core/agents";
import type { TaskFailureReason } from "@multica/core/types";
import type { AgentAvailability, Workload } from "@multica/core/agents";
// Visual mapping for the two presence dimensions, kept in matching shape
// so consumers can pick which to render. The two are independent — the
// dot reads only from availabilityConfig, the last-task chip reads only
// from taskStateConfig.
// dot reads only from availabilityConfig, the workload chip reads only
// from workloadConfig.
//
// Color tokens map to project semantic tokens (no hardcoded Tailwind colors):
//
@@ -24,16 +21,17 @@ import type { TaskFailureReason } from "@multica/core/types";
// unstable → warning (amber) — pairs with the runtime card's amber
// offline → muted-foreground (gray)
//
// LAST TASK STATE (drives the optional last-task chip on focused surfaces):
// running → brand (blue) has activity
// completed → success (green) all good
// failed → destructive (red)
// cancelled → muted (gray)
// idle → muted (gray) no history
// WORKLOAD (drives the optional workload chip on focused surfaces):
// working → brand (blue) has activity
// queued → warning (amber) anomaly: nothing running but tasks
// waiting (typically stuck on offline
// runtime; brief flash on online is
// a harmless race)
// idle → muted (gray) nothing on the plate
//
// Critically: `failed` colour appears ONLY in the last-task chip, never
// on the dot. A runtime-healthy agent whose last task failed shows a
// green dot + a red "Failed" chip — the dot stops being sticky-red.
// `failed` / `completed` / `cancelled` deliberately have no top-level visual
// those are historical context, surfaced via Recent Work + Inbox, not
// list-level summary state.
export interface AvailabilityVisual {
label: string;
@@ -74,7 +72,7 @@ export const availabilityOrder: AgentAvailability[] = [
"offline",
];
export interface TaskStateVisual {
export interface WorkloadVisual {
label: string;
// Foreground colour for icon + label text.
textClass: string;
@@ -82,26 +80,19 @@ export interface TaskStateVisual {
icon: LucideIcon;
}
export const taskStateConfig: Record<LastTaskState, TaskStateVisual> = {
running: {
label: "Running",
export const workloadConfig: Record<Workload, WorkloadVisual> = {
working: {
label: "Working",
textClass: "text-brand",
icon: Loader2,
},
completed: {
label: "Completed",
textClass: "text-success",
icon: CheckCircle2,
},
failed: {
label: "Failed",
textClass: "text-destructive",
icon: XCircle,
},
cancelled: {
label: "Cancelled",
textClass: "text-muted-foreground",
icon: PauseCircle,
queued: {
// Amber chip: nothing running but tasks waiting. On an offline runtime
// this is the "stuck" signal we explicitly surface (replacing the old
// misleading "Running 0/N +Mq" copy).
label: "Queued",
textClass: "text-warning",
icon: Clock,
},
idle: {
label: "Idle",
@@ -110,22 +101,5 @@ export const taskStateConfig: Record<LastTaskState, TaskStateVisual> = {
},
};
// Order used by last-run filter chips. Actionable signals first
// (running / failed) before passive ones (idle / cancelled).
export const lastTaskOrder: LastTaskState[] = [
"running",
"failed",
"completed",
"cancelled",
"idle",
];
// Human-readable copy for the back-end task failure reason enum. Surfaced
// in the hover card and detail header when lastTask === "failed".
export const failureReasonLabel: Record<TaskFailureReason, string> = {
agent_error: "Agent execution error",
timeout: "Task timed out",
runtime_offline: "Daemon offline",
runtime_recovery: "Daemon restarted",
manual: "Cancelled by user",
};
// Order used in any future workload chip group; actionable signals first.
export const workloadOrder: Workload[] = ["working", "queued", "idle"];

View File

@@ -44,7 +44,9 @@ import {
} from "./trigger-config";
import type { TriggerConfig } from "./trigger-config";
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
import type { AgentTask } from "@multica/core/types/agent";
import { ReadonlyContent } from "../../editor";
import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
function formatDate(date: string): string {
@@ -63,11 +65,34 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
function RunRow({ run }: { run: AutopilotRun }) {
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
const wsPaths = useWorkspacePaths();
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
const StatusIcon = cfg.icon;
// For runs with a task_id (run_only mode), build a minimal AgentTask so
// TranscriptButton can lazy-load the execution transcript.
const syntheticTask: AgentTask | null = run.task_id
? {
id: run.task_id,
agent_id: agentId,
runtime_id: "",
issue_id: "",
status:
run.status === "running" ? "running" :
run.status === "completed" ? "completed" :
run.status === "failed" ? "failed" :
"queued",
priority: 0,
dispatched_at: null,
started_at: run.triggered_at || null,
completed_at: run.completed_at || null,
result: null,
error: run.failure_reason || null,
created_at: run.created_at,
}
: null;
const content = (
<>
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
@@ -83,6 +108,14 @@ function RunRow({ run }: { run: AutopilotRun }) {
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(run.triggered_at || run.created_at)}
</span>
{syntheticTask && !run.issue_id && (
<TranscriptButton
task={syntheticTask}
agentName={agentName}
isLive={run.status === "running"}
title="View execution log"
/>
)}
</>
);
@@ -438,7 +471,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
) : (
<div className="rounded-md border overflow-hidden">
{runs.map((run) => (
<RunRow key={run.id} run={run} />
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
))}
</div>
)}

View File

@@ -130,38 +130,40 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
const StatusIcon = statusCfg.icon;
return (
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
<div className="group/row flex flex-col gap-2 border-b px-4 py-3 text-sm transition-colors hover:bg-accent/40 sm:h-11 sm:flex-row sm:items-center sm:gap-2 sm:border-b-0 sm:px-5 sm:py-0">
<AppLink
href={wsPaths.autopilotDetail(autopilot.id)}
className="flex min-w-0 flex-1 items-center gap-2"
className="flex min-w-0 items-center gap-2 sm:flex-1"
>
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{autopilot.title}</span>
</AppLink>
{/* Agent */}
<span className="flex w-32 items-center gap-1.5 shrink-0">
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
<span className="truncate text-xs text-muted-foreground">
{getActorName("agent", autopilot.assignee_id)}
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-xs sm:contents sm:pl-0">
{/* Agent */}
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground sm:w-32 sm:shrink-0">
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
<span className="truncate">
{getActorName("agent", autopilot.assignee_id)}
</span>
</span>
</span>
{/* Mode */}
<span className="w-24 shrink-0 text-center text-xs text-muted-foreground">
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
</span>
{/* Mode */}
<span className="text-muted-foreground sm:w-24 sm:shrink-0 sm:text-center">
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
</span>
{/* Status */}
<span className={cn("flex w-20 items-center justify-center gap-1 shrink-0 text-xs", statusCfg.color)}>
<StatusIcon className="h-3 w-3" />
{statusCfg.label}
</span>
{/* Status */}
<span className={cn("flex items-center gap-1 sm:w-20 sm:shrink-0 sm:justify-center", statusCfg.color)}>
<StatusIcon className="h-3 w-3" />
{statusCfg.label}
</span>
{/* Last run */}
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
</span>
{/* Last run */}
<span className="text-muted-foreground tabular-nums sm:w-20 sm:shrink-0 sm:text-right">
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
</span>
</div>
</div>
);
}
@@ -198,7 +200,7 @@ export function AutopilotsPage() {
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<>
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 sm:flex">
<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" />
@@ -206,9 +208,9 @@ export function AutopilotsPage() {
<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">
<div className="space-y-2 p-4 sm:space-y-1 sm:p-5 sm:pt-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
<Skeleton key={i} className="h-[72px] w-full sm:h-11" />
))}
</div>
</>
@@ -246,7 +248,7 @@ export function AutopilotsPage() {
) : (
<>
{/* Column headers */}
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground sm:flex">
<span className="shrink-0 w-4" />
<span className="min-w-0 flex-1">Name</span>
<span className="w-32 shrink-0">Agent</span>

View File

@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { cn } from "@multica/ui/lib/utils";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
@@ -14,6 +15,10 @@ interface ChatInputProps {
onStop?: () => void;
isRunning?: boolean;
disabled?: boolean;
/** True when the user has no agent available — disables the editor and
* surfaces a distinct placeholder. Kept separate from `disabled` so
* archived-session copy stays untouched. */
noAgent?: boolean;
/** Name of the currently selected agent, used in the placeholder. */
agentName?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
@@ -30,6 +35,7 @@ export function ChatInput({
onStop,
isRunning,
disabled,
noAgent,
agentName,
leftAdornment,
rightAdornment,
@@ -54,11 +60,12 @@ export function ChatInput({
const handleSend = () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || isRunning || disabled) {
if (!content || isRunning || disabled || noAgent) {
logger.debug("input.send skipped", {
emptyContent: !content,
isRunning,
disabled,
noAgent,
});
return;
}
@@ -69,19 +76,50 @@ export function ChatInput({
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
onSend(content);
editorRef.current?.clearContent();
// Drop focus so the caret doesn't keep blinking under the StatusPill /
// streaming reply that's about to take over the user's attention. The
// input is also `disabled` once isRunning flips, and a focused-but-
// disabled editor reads as a stale cursor. We deliberately don't auto-
// refocus on completion — that would interrupt the user if they're
// selecting text from the assistant reply; one click to refocus is
// a fair price for not stealing focus mid-action.
editorRef.current?.blur();
clearInputDraft(keyAtSend);
setIsEmpty(true);
};
const placeholder = disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
const placeholder = noAgent
? "Create an agent to start chatting"
: disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
return (
<div className="px-5 pb-3 pt-0">
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
<div
className={cn(
"px-5 pb-3 pt-0",
// Outer wrapper carries the disabled cursor. Inner card sets
// pointer-events-none, which suppresses hover (and therefore
// any cursor of its own) — splitting the two layers lets hover
// bubble back here so the browser actually reads cursor.
noAgent && "cursor-not-allowed",
)}
>
<div
className={cn(
"relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand",
// Visual + interaction lock when there's no agent. We don't
// toggle ContentEditor's editable mode (Tiptap can't switch
// cleanly post-mount, and the prop has been removed); instead
// we drop pointer events at the wrapper level so clicks miss
// the editor entirely, and dim the surface so it reads as
// "disabled" rather than "broken".
noAgent && "pointer-events-none opacity-60",
)}
aria-disabled={noAgent || undefined}
>
{topSlot}
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
@@ -113,7 +151,7 @@ export function ChatInput({
{rightAdornment}
<SubmitButton
onClick={handleSend}
disabled={isEmpty || !!disabled}
disabled={isEmpty || !!disabled || !!noAgent}
running={isRunning}
onStop={onStop}
/>

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