Compare commits

..

56 Commits

Author SHA1 Message Date
Lambda
117c7ba6ae fix(inbox): keep scope/availability caches fresh on issue reassign + relation changes
- issue:updated WS + useUpdateIssue / useBatchUpdateIssues now invalidate
  inboxKeys.list + scopeCounts so assignee_scope-derived chip filtering,
  badges, and bulk operations don't lag the actual scope.
- onInboxInvalidate / onInboxIssueDeleted also flush scopeCounts so
  single-row archived/read events and CASCADE-deletes refresh the chip
  badge alongside the list.
- agent / member / squad refresh handlers invalidate
  inboxKeys.resourceAvailability so chip enabled state reacts to the
  first owned-agent / squad-membership / squad creation event instead
  of waiting for reload.
- Inbox page header unread count derives from filtered items rather
  than the global useInboxUnreadCount so the badge matches the visible
  list; sidebar / desktop badge stay on the global count.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 23:18:36 +08:00
Lambda
7ac797fcd8 refactor(inbox): rename batch-archived operation literal archive_all_read → archive_read
RFC v4 final naming (per Xeon directive on MUL-2426): the three places that
must agree on the operation literal are the server `inbox:batch-archived`
event payload, the bulk-endpoint handler switch, and the frontend
`InboxBatchArchiveOperation` discriminated union. UI menu / error i18n keys
are unrelated and stay as-is.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:00:37 +08:00
Lambda
fd913a2596 feat(inbox): add assignment filter chips (assigned to me / my agent / my squad) (MUL-2426)
Implements RFC v3 + the two v4 deltas (operation field on inbox:batch-archived,
scoped.id/issue_id alias). Server-side first (SQL + handler + WS payload +
resource-availability), then frontend (chip UI + store + dynamic bulk labels).

Backend:
- Migration 095: SQL function squad_involves_user mirroring the
  ListIssues involves_user_id semantics so the inbox scope predicate
  can't drift from My Issues.
- ListInboxItems now tags each row with assignee_scope (me / my_agent /
  my_squad / other / none) and accepts an optional scopes filter.
- New endpoints: GET /api/inbox/scope-counts (post-dedup), GET
  /api/inbox/resource-availability (decoupled chip-disabled signal).
- mark-all-read + 3 archive endpoints accept ?scope=...; archive-* emit
  inbox:batch-archived with operation + scope so listeners can pick
  the right predicate when applying precise cache updates.

Frontend:
- New workspace-aware inbox-scope-store; default = all 3 chips selected.
- resolveInboxFilter implements the all / subset / empty algorithm.
- InboxFilterChips component with disabled-but-selected state machine
  (S1-S4) and tooltips, sourced from resource-availability rather than
  scope counts.
- Bulk actions disabled in empty mode, label swaps to "filtered" copy
  in subset mode.
- WS handlers for inbox:batch-read / inbox:batch-archived wired in to
  refresh other devices.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:55:33 +08:00
Naiyuan Qing
39f43a9a98 refactor(editor): unify attachment rendering into a single <Attachment> component (#2850)
Collapse the five separate attachment render paths (file-card NodeView,
image NodeView, readonly markdown img/fileCard renderers, AttachmentList
standalone fallback, and the parallel packages/ui/markdown renderer) into
one <Attachment attachment={a} /> dispatcher.

Fixes a P0 visual regression: a PNG attached to a message but not inlined
in the markdown body used to render as a gray "file card" because
getPreviewKind() lacked an "image" branch and image rendering bypassed
the dispatcher entirely. Now every surface routes through <Attachment>,
so the same PNG renders as a real <img> with hover toolbar and
preview-modal everywhere.

Key changes:
- PreviewKind gains "image"; getPreviewKind() detects image/* + common
  extensions before the html/text branches (so svg stays image, not text).
- AttachmentPreviewModal gains case "image" (replaces the standalone
  ImageLightbox, which is deleted).
- New packages/views/editor/attachment.tsx owns all kind-aware routing
  (image | html | file) and dispatches preview modal + download via the
  existing useAttachmentPreview / useDownloadAttachment hooks. Subsumes
  the deleted AttachmentBlock.
- AttachmentInput.url accepts a forceKind hint so callers that *know*
  the structural kind (markdown ![](url), Tiptap image node) skip the
  filename-based autodetect — fixes a regression where empty or
  descriptive alt text would route an image to the file-card chrome.
- Tiptap NodeViews (file-card.tsx, image-view.tsx) shrink to thin
  wrappers that forward editor hints (selected, deleteNode, uploading)
  to <Attachment>.
- ReadonlyContent and AttachmentList each mount their own
  AttachmentDownloadProvider so url → record resolution works outside
  ContentEditor's provider.
- packages/ui/markdown gains optional renderImage / renderFileCard slot
  props; packages/views/common/markdown.tsx injects <Attachment> into
  those slots and threads message attachments through to chat /
  skill-file viewers.
- chat-message-list passes message.attachments to every <Markdown> call
  site and renders a standalone AttachmentList under each bubble for
  attachments not referenced in the body.

Tests: attachment.test.tsx covers 9 scenarios (record image / pdf / html;
url-only image with resolver hit and miss; uploading state; editable
delete; forceKind regression). attachment-preview-modal.test.tsx gains
image-dispatch cases. 652/652 unit tests pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:19 +08:00
Kagura
59617f376e feat(auth): make auth token TTL configurable via AUTH_TOKEN_TTL env var (MUL-2371) (#2713)
* feat(auth): make auth token TTL configurable via AUTH_TOKEN_TTL env var

Add AUTH_TOKEN_TTL environment variable (in seconds) to override the
hardcoded 30-day auth token lifetime. Self-hosted deployments on trusted
networks can set a longer value to avoid frequent magic-link
re-authentication.

The value is read once at startup and cached. Invalid or missing values
fall back to the 30-day default with a warning log.

Closes #2685

* refactor(auth): extract parseAuthTokenTTL for testability

Address review feedback: extract pure parse function from sync.Once
wrapper so the parsing logic can be unit-tested independently.
Add TestParseAuthTokenTTL with table-driven cases.

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

* refactor(auth): accept Go duration strings + hoist shared TTL in SetAuthCookies

Address nice-to-have review feedback from Bohan-J:
- parseAuthTokenTTL now tries time.ParseDuration first (e.g. '8760h'),
  falling back to ParseInt for integer seconds
- Warn on unreasonable values (>10 years) but still accept them
- Hoist AuthTokenTTL() and time.Now() in SetAuthCookies so both
  cookies share the exact same expiry
- Add security trade-off note in .env.example
- Add 5 new test cases for duration strings

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix: use AuthTokenTTL() in CloudFront middleware, guard ParseInt overflow

Address review feedback from Bohan-J (round 2):

1. CloudFront refresh middleware (cloudfront.go:21) was hardcoding
   30*24*time.Hour instead of using auth.AuthTokenTTL(). Now calls
   AuthTokenTTL() so the middleware respects AUTH_TOKEN_TTL env var.

2. parseAuthTokenTTL integer-seconds branch: very large values like
   9999999999 would silently overflow int64 when multiplied by
   time.Second. Added overflow guard comparing against
   math.MaxInt64/int64(time.Second) before the multiplication.

3. Updated AuthTokenTTL() doc comment to reflect that it accepts
   Go duration strings or integer seconds (not just seconds).

4. Added middleware test (cloudfront_test.go) verifying short
   AUTH_TOKEN_TTL produces short cookie expiry, not 30-day hardcode.
   Also covers nil signer and existing-cookie-skip cases.

5. Added integer overflow test case to cookie_test.go.

* style: run gofmt on cookie.go and cookie_test.go

---------

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-05-19 16:22:07 +08:00
Bohan Jiang
9a577f3e11 fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir (MUL-2416) (#2849)
* fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir

OpenCode resolves its project discovery root from `--dir` and `PWD`
before falling back to `process.cwd()`. The daemon set `cmd.Dir =
workDir` but never overrode the inherited `PWD`, so OpenCode walked
from the daemon's shell directory and silently bypassed the per-task
workdir — agents lost visibility into `.opencode/skills/` and
`AGENTS.md`, falling back to whatever global skills the host had
installed (MUL-2416).

- Pass `opencode run --dir <workDir>` and override `PWD=<workDir>` in
  the child env so AGENTS.md walk-up + `.opencode/skills` project
  config scan both anchor on the task workdir.
- Block `--dir` from custom args so user overrides cannot re-introduce
  the regression.
- Plumb skill `description` from DB through service / daemon /
  execenv. `writeSkillFiles` synthesizes a YAML frontmatter block
  (`name`, optional `description`) when the stored content lacks one,
  since runtimes like OpenCode silently drop SKILL.md files without a
  parseable `name`. Existing frontmatter is preserved unchanged so
  upstream-imported skills (GitHub / ClawHub / Skills.sh) keep their
  hand-shaped metadata.

Tests:
- New fake-CLI test confirms argv carries `--dir <workDir>` and the
  child sees `PWD=<workDir>`.
- New test confirms a user-supplied `--dir` in custom_args is dropped.
- New execenv tests cover synthesized frontmatter and preservation of
  pre-existing frontmatter.

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

* fix(runtimes): inject SKILL.md `name` when upstream frontmatter omits it

Skills imported with frontmatter that sets `description` but leaves `name`
implicit (relying on the directory slug, as common in GitHub/Skills.sh
imports) still hit OpenCode's "no parseable name → drop" path because the
DB Name fallback never made it into the SKILL.md body. ensureSkillFrontmatter
now scans the existing block and, when name is missing or empty, prepends
`name: <slug>` while preserving description, body, and any runtime-specific
keys verbatim.

Also tighten yamlEscapeInline to always double-quote so descriptions that
look like YAML keywords (`null`, `true`, `[foo]`, `{x: y}`, `2024-01-01`)
parse as strings rather than getting reinterpreted and rejected.

Adds regression test for the nameless-frontmatter case and updates the
existing OpenCode skill test for the always-quoted description format.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:21:02 +08:00
Bohan Jiang
7be3838ada feat(transcript): add sort direction toggle to agent transcript dialog (MUL-2368) (#2848)
Adds a header toggle that lets users flip the agent transcript between
chronological (oldest first, current behavior) and newest-first. The
preference is persisted via a small Zustand store. Default stays
chronological so existing readers see no behavior change.

Sort is a pure presentation concern — the underlying timeline (seq
numbers, filter keys, segment navigation) is untouched. Toggling resets
the scroll container to the top so the user lands on the newest end of
the chosen direction. Copy-all respects the displayed order so the
exported text matches what's on screen.

Scope is limited to the task transcript dialog per the MVP plan; the
issue execution log and agent activity tab are out of scope and may be
revisited once this interaction validates.

Closes GH #2736.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:03:31 +08:00
Bohan Jiang
98ef021d1d feat(projects): add Project Gantt view (MUL-1881) (#2843)
* feat(projects): add Project Gantt view (MUL-1881)

Adds Gantt as a third option in the Project page's view toggle (Board /
List / Gantt). Bars span start_date → due_date; issues with only one
date render as markers, issues with neither are collapsed into an
Unscheduled section. Toolbar exposes day/week/month zoom and a
show-completed toggle. The Gantt view shares the existing IssuesHeader
filters/sort.

Implementation is self-rendered SVG/HTML — no new dependencies. UTC
day-aligned date math keeps bars on the right columns regardless of
viewer timezone.

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

* fix(projects): scope Gantt to project surface + warn on hidden pages

- IssuesHeader / IssueDisplayControls now take `allowGantt` (default false);
  only Project Detail opts in. /issues, /my-issues and the actor panel no
  longer expose a Gantt option that silently fell through to List, and the
  toggle icon falls back to List when a stored `viewMode === "gantt"` lands
  on a surface that doesn't render it.
- Project Gantt now surfaces a banner with hidden-issue count plus a
  Load-all action that drains every remaining paginated page into the
  cache via the new `useLoadAllRemaining` helper. Pagination summary comes
  from `myIssueListPaginationOptions`, which shares the existing cache key
  with `myIssueListOptions` so totals stay in sync with Board/List.
- ScheduledRow normalizes a `start_date > due_date` anomaly to min/max and
  outlines the bar with a destructive ring + tooltip note, instead of
  silently dropping the row.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 15:55:57 +08:00
Jiayuan Zhang
6f21cb8f3e [codex] Simplify onboarding runtime bootstrap (#2836)
* feat(onboarding): simplify runtime bootstrap

* fix(onboarding): close private-helper reuse hole and guide-issue nav race

- server: when bootstrap looks for an existing Multica Helper, require
  Visibility="workspace" so a private helper owned by another member
  can't be auto-assigned to the onboarding issue (and trigger a task as
  that private agent), which would have bypassed canAccessPrivateAgent.
- web onboarding page: refreshMe() inside bootstrap flips hasOnboarded
  before onComplete fires, letting the guard's router.replace overtake
  onComplete's router.push to the new guide issue. Mark the page as
  "completing" right before navigating so the guard stays silent during
  the in-flight transition.

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

* fix(runtimes): escape daemon command literals to satisfy i18next/no-literal-string

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <lambda@multica.ai>
2026-05-19 09:52:35 +02:00
Bohan Jiang
d7e58760f3 fix(runtimes): exempt CLI command literals in Connect Remote dialog from i18n rule (#2841)
The two `<code>` blocks in the "having trouble?" disclosure of the
Connect Remote dialog render literal shell commands ("multica daemon
status" and "multica daemon logs -f"). The `i18next/no-literal-string`
rule (enforced as error across packages/views) flagged them, turning
@multica/views#lint red on main since the dialog landed.

These strings are inherently locale-agnostic — they are the actual
commands users type into a shell, identical in every language. Wrapping
them in t() would be wrong (translators would have no source-of-truth
about whether the binary name `multica` or the subcommand `daemon` could
be translated; the answer is "never").

Mark them as exempt with `eslint-disable-next-line i18next/no-literal-string`
+ a one-line comment explaining why. Mirrors how shell-command snippets
are treated elsewhere in the repo.

Verification:
- `pnpm --filter @multica/views lint` → 0 errors (was 2). 13 remaining
  warnings are pre-existing in other files and don't fail CI.
- Cascaded failures (@multica/views#typecheck, web/desktop builds) on CI
  were strictly downstream of the lint failure; they'll go green once
  lint passes.
2026-05-19 15:01:40 +08:00
Bohan Jiang
6e0f7b0f36 feat(settings): allow editing workspace issue prefix (MUL-2369) (#2809)
* feat(settings): allow editing workspace issue prefix (MUL-2369)

Workspace admins can now change the issue prefix from Settings → General.
The change is gated by a confirmation dialog that warns about external
references (PR titles, branch names, links) breaking, because issue
identifiers are rendered as `prefix-N` on the fly — changing the prefix
effectively renames every existing issue.

Refs https://github.com/multica-ai/multica/issues/2797

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

* fix(settings): invalidate issue cache when workspace prefix changes (MUL-2369)

Issue identifiers (`MUL-123`) are recomputed from `workspace.issue_prefix`
at read time, so cached issues kept showing the old `OLD-N` keys after a
prefix change. Without invalidation the confirm dialog's "all issues will
be renumbered" promise was broken until a hard refresh — and other tabs
receiving the `workspace:updated` WS event saw the same drift.

- WorkspaceTab: after a prefix-changing save, invalidate `issueKeys.all`
  in addition to the workspace list. Non-prefix saves stay cheap.
- Realtime: split `workspace:updated` out of the generic `workspace`
  refresh into a specific handler that compares cached vs incoming
  `issue_prefix` and invalidates issues only when it actually changed.
- Docs: align the "uppercase" language with the actual UI/backend rule
  (uppercase letters and digits, up to 10 chars).

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:47:34 +08:00
Bohan Jiang
b5102eb3d2 feat(cli): add workspace switch + current commands (MUL-2386) (#2838)
`multica workspace switch <id|slug>` is the product-semantic entry point for
changing the default workspace on the current profile. It looks the target up
in the user's accessible workspace list (an access check by construction —
the server only returns workspaces the user is a member of), persists the
chosen UUID via the existing CLI config layer, and prints the resolved name.
`config set workspace_id` stays as the low-level escape hatch.

`multica workspace switch` resolves the workspace before saving, so an
unknown id or slug fails fast and leaves the previous default intact.

`multica workspace current` and a `*` marker in `multica workspace list`
expose which workspace commands without --workspace-id/MULTICA_WORKSPACE_ID
will target. `multica login` reuses the same marker when listing discovered
workspaces and points multi-workspace users at switch.

Docs gain a "Working with multiple workspaces" section spelling out the
resolution priority (--workspace-id flag > env > profile default) and
calling out config set workspace_id as low-level.

Addresses GitHub#2750.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:43:20 +08:00
Bohan Jiang
e19f7967b9 feat(prompt): thread-first comment reads for agent runs (MUL-2387) (#2816)
* feat(prompt): thread-first comment reads for agent runs (MUL-2387)

PR #2787 added --thread / --recent / --before / --before-id to the
ListComments API and CLI but kept the agent prompt steering at the
legacy "dump everything" recipe. On a long-running issue the flat dump
burns context on chatter unrelated to the trigger; agents acting on the
trigger want the trigger's thread first.

Prompt updates:

- Comment-triggered Workflow (runtime_config.go) now anchors step 2 on
  `multica issue comment list <issue-id> --thread <trigger-comment-id>
  --output json`. Fallback offers `--recent 20 --output json` with the
  stderr `Next thread cursor: --before <ts> --before-id <root-id>` line
  feeding the next-page cursor. `--since` is preserved and explicitly
  marked combinable with --thread / --recent.
- Per-turn buildCommentPrompt (prompt.go) carries the same thread-first
  guidance so a Codex-style runtime that re-reads the per-turn message
  every iteration gets the same steering, even if it ignores the
  injected runtime config.
- Assignment-triggered Workflow keeps the mandatory full-history rule
  (MUL-1124) but now also points at `--recent 20` as the long-issue
  alternative — this is the place that previously had no thread-aware
  guidance at all.
- Default fallback prompt (no trigger comment, no chat, no autopilot,
  no quick-create) gains the same --recent hint without --thread (no
  comment to anchor on).
- Available Commands core line surfaces the new flags so the discovery
  path matches the workflow guidance.

Default CLI/API semantics are unchanged: the unparameterized list still
returns the full chronological dump capped at 2000, --since still works
on its own, and the desktop UI is untouched.

Tests:

- prompt_test.go: TestBuildPromptCommentTriggerPromotesThreadReads pins
  --thread <triggerID>, --recent 20, the stderr cursor phrasing, and
  the absence of the legacy "returns all comments" prose.
- prompt_test.go: TestBuildPromptDefaultMentionsRecent guards the
  no-trigger fallback (mentions --recent, must NOT mention --thread).
- execenv_test.go: TestInjectRuntimeConfigCommentTriggerThreadFirstReads
  asserts the comment-triggered Workflow steers at --thread/--recent,
  the Available Commands line surfaces the new flags, and the legacy
  "read the conversation (returns all comments...)" string is gone.
- execenv_test.go: TestInjectRuntimeConfigAssignmentTriggerMentionsRecent
  keeps the mandatory full-history rule pinned AND asserts --recent is
  offered as the long-issue alternative.

Also fixes the recent+since cursor nit Elon flagged in #2787's second
review: when `since` empties the page, the `len(seenRoot) >= recentN`
check used to emit a cursor anyway. Pagination walks threads in
strictly decreasing last_activity_at — if every comment in this page is
<= since, every older thread's last_activity is also <= since by
transitivity, so the cursor would only invite the caller into a
guaranteed-empty walk. Now suppressed; new tests pin both branches
(suppressed when empty, retained when at least one row passes since).

MUL-2387

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

* fix(comments): suppress recent+since cursor when head thread past since (MUL-2387)

Previous suppression only tripped when the `since` filter emptied the
page. That missed the mixed case Elon flagged in #2787's second review:
the page keeps rows from fresher threads but the head (oldest-active)
thread already sits at or before `since`, so every older page is
guaranteed empty too. Predicating on `headLast <= since` covers both
cases.

Add a recent=2 + since fixture that pins the mixed scenario: root1
(last_activity = base+3m) is filtered out, root2 stays, and the cursor
is suppressed even though the body is non-empty.

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

* fix(prompt): clarify --recent is paging, not a replacement (MUL-2387)

Address Elon's second-pass nit on #2816: the assignment-trigger workflow
in runtime_config.go used "you may switch to --recent 20", which reads as
a replacement for the mandatory full-history rule. Rephrase --recent as a
paging strategy ("read the full history page-by-page, not a shortcut that
replaces it") so it cannot conflict with the rule it lives next to.

The default per-turn prompt in prompt.go opened with "If you need comment
history" — that soft conditional contradicts the runtime workflow's
mandatory read. Move it to a neutral "For comment history, follow the
rule in your runtime workflow file" framing that defers to whatever the
workflow says (mandatory for assignment, optional elsewhere) instead of
encoding its own policy.

Keep the runtime/prompt dual-layer fallback intact — different runtimes
propagate the config file vs. the per-turn user prompt with varying
fidelity, so both surfaces need the guidance.

Tests pin the new phrasing against regression:

- TestBuildPromptDefaultMentionsRecent now also forbids "If you need
  comment history" from sneaking back in.
- TestInjectRuntimeConfigAssignmentTriggerMentionsRecent now also forbids
  "you may switch to" / "switch to `--recent" replacement phrasing.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:42:35 +08:00
Jiayuan Zhang
ccd9e6cdfb feat(runtimes): simplify "Add a computer" dialog (MUL-2408) (#2839)
- Align Runtimes connect flow with Onboarding CLI install: install.sh + multica setup
- Drop manual "I've started the daemon" step; subscribe to daemon:register WS and auto-advance
- Rename Connect remote machine -> Add a computer, remove EC2-specific copy
- Rework UI per web design guidelines (focus rings, aria labels, live status, footer alignment)
- Fix DialogFooter negative-margin overflow with p-0 content; use outline Cancel button

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 08:33:32 +02:00
Jiayuan Zhang
8d30d76300 feat(dashboard): add 1d range to workspace Usage tab (#2837)
* feat(dashboard): add 1d time range to workspace Usage tab

1d means "today" — the natural calendar day from 00:00 UTC, matching the
rollup's bucket_date axis — not the trailing 24 hours. The client-side
dailyCutoffIso filter is now applied in daily dim too so 1d collapses
strictly to today even at the midnight UTC edge where the server's
wall-clock since cutoff would otherwise include yesterday.

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

* fix(dashboard): scope `1d` to today only on aggregate endpoints

The pre-aggregated `byAgent` / `runTime` dashboard endpoints leaked
yesterday into the agent leaderboard and KPI cards for the `1d` time
range because `parseSinceParam(days=1)` returned `now-24h` (wall clock)
and the downstream SQL then applied `DATE_TRUNC('day', @since)`, which
landed on yesterday 00:00 UTC. The PR's client-side `dailyCutoffIso`
filter could only fix the date-bearing daily endpoints; aggregate
responses are already collapsed across dates.

Anchor `parseSinceParam` at UTC start-of-today instead, so `days=N`
covers N natural calendar days (today + N-1 prior). This matches the
frontend `dailyCutoffIso = today - (days-1)` semantic that the
workspace dashboard already assumes, and removes the off-by-one that
previously made `30d` return 31 buckets.

The runtime-detail page uses `parseSinceParamInTZ` (timezone-aware),
which is unchanged — it has no `1d` option.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 08:28:04 +02:00
Jiayuan Zhang
0339de54e7 add web design guidelines skill (#2832) 2026-05-19 12:09:41 +08:00
Jiayuan Zhang
c577a29c10 feat(onboarding): v2 per-question questionnaire (source/role/use_case) (#2814)
* feat(onboarding): per-question v2 questionnaire (source/role/use_case)

Replaces the 3-questions-on-one-screen gate with three lightweight,
individually-skippable steps. New step order:

  welcome → source → role → use_case → workspace → runtime → agent → first_issue

- New v2 questionnaire schema: source/role/use_case + per-slot
  `*_skipped` markers. `team_size` removed.
- Click-to-advance card grid with lucide + emoji icons (RFC Option B).
- Skip is a footer text button; Other expands a free-text input.
- Recommendation table updated for new role × use_case vocabulary,
  with use_case-only fallback when role is skipped.
- DB migration v1 → v2 maps existing role/use_case answers and drops
  team_size; historical nulls stay null (not retroactively skipped).
- Re-entry treats skipped slots as fresh; analytics record kept in DB.
- onboarding_questionnaire_submitted event payload updated:
  source replaces team_size, per-slot skip booleans added.

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

* fix(onboarding): tighten question UX (Continue, layout, brand icons)

Address review feedback on Source/Role/Use-case:

- Replace auto-advance with an explicit Continue button so selections
  are reviewable. Continue is disabled until something is picked (and,
  for Other, until the free-text input is non-empty).
- Move Back/Skip/Continue inline under the option grid; drop the
  duplicate Back from the top header — the page now has a single,
  anchored action row.
- Swap the placeholder lucide marks for real brand SVGs on Source:
  Google, X, LinkedIn, YouTube, and an OpenAI mark for the AI-assistant
  option. Generic options stay on lucide.
- Replace the awkward expanded underline input on the Other card with
  an inline borderless input that swaps in for the label slot, so the
  Other state has the same height and weight as the other cards.

E2E smoke test updated to click Continue between question steps.

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

* fix(onboarding): unify step nav, rename Runtime step around "where agents run"

- Refactor the Source/Role/Use case questionnaire steps to use the same
  3-region chrome (header with Back + step indicator, scrolling main,
  sticky footer with Skip + Continue) that Workspace/Runtime/Agent
  already use, so the Back/Skip/Continue affordances stay in the same
  on-screen position across the whole flow.
- Reframe the Runtime step around the user-visible question — "Where
  will your agents run?" — instead of the internal "runtime" concept.
  The aside panel keeps the educational "What's a runtime?" copy for
  users who want to learn.
- Drop the hard-coded "Step 3 · Runtime" eyebrow on the web fork step:
  Runtime is now step 5 of 7 after the per-question split, and the
  step indicator already shows the correct count.

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

* fix(onboarding): tighten Skip/Continue spacing in step footer

Group Skip and Continue inside a sub-flex with gap-2 so they read as a
single action cluster on the right, while the status hint still anchors
left via mr-auto. Applied to both the questionnaire steps and the
runtime step so the footer layout stays consistent across onboarding.

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

* fix(onboarding): move Skip/Continue inline below form, drop sticky footer

The sticky bottom footer left a large dead zone between the form
content and the action buttons — most onboarding steps only fill the
top third of the viewport. Move the hint + Skip + Continue inline,
directly below the form/options grid, so the buttons sit where the eye
already is after picking an option.

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

* fix(onboarding): match Skip button size to Continue (size="lg")

Skip used the default button size (h-8) while Continue used size="lg"
(h-9), so the two adjacent action buttons rendered visibly different
heights. Promote Skip to size="lg" in step-question and
step-runtime-connect so they line up.

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

* fix(onboarding): reframe step 3 as 'connect a computer' / 'pick an agent runtime'

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

* fix(onboarding): replace cloud waitlist with "Coming soon", reword CLI intro

- Web Step 3 cloud card: remove "Join waitlist" CTA + dialog and render a
  static "Coming soon" badge instead. Drops CloudWaitlistDialog, the
  cloud DialogState, waitlistSubmitted local state, and the
  onWaitlistSubmitted prop on StepPlatformFork (desktop's
  StepRuntimeConnect still owns its own waitlist path).
- Tighten cloud_subtitle to drop the "join the waitlist" half now that
  the action is gone.
- cli_install.intro: "AI coding tool" → "agent runtime", EN + zh-Hans.

Tests updated to match: asserts the Coming soon badge is non-actionable
and drops the four cloud-dialog scenarios (now unreachable).

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

* fix(onboarding): refresh button, "agent runtime" wording, coming-soon card

Three fixes on the desktop Step 3 empty state per review:

1. Empty headline + hints now say "agent runtime", matching the
   picker-context terminology established earlier in this PR.
2. Add a Refresh button (header pill in Found, inline with the
   headline in Empty). Desktop wires it to restart the bundled
   daemon so a freshly-installed Claude/Codex/Cursor CLI is picked
   up — the daemon's PATH probe runs once at boot, so without a
   restart the install would only take effect on next launch.
3. "Use a cloud computer" loses the waitlist dialog and renders as
   a disabled "Coming soon" badge, aligning with the web fork.

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

* fix(onboarding): address review follow-ups (i18n, step-order, version, tests)

- runtime-aside-panel: point "Learn more" to /docs/install-agent-runtime,
  branching by language so zh users land on /docs/zh/...
- zh-Hans: unify Cloud "Coming soon" wording to "即将推出"; translate
  step_workspace.preview.more_meta ("and more" -> "等等")
- onboarding-flow: derive forward navigation from ONBOARDING_STEP_ORDER
  via advanceFrom(curr) so inserting/reordering a step only requires
  editing the canonical array; runtime → agent/first_issue branch keeps
  its bespoke routing with a comment explaining why
- onboarding handler: gate questionnaireAnswers.complete() on
  Version == 2 so a future schema bump can't be silently mis-counted
  against v2 funnel semantics
- add unit tests for step-source / step-role / step-use-case (option
  click, Skip patch, Other free-text) and step-question shell
  (canContinue + pendingOther state machine)

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

* fix(onboarding): rename useCaseFallback to fallbackFromUseCase

ESLint's react-hooks/rules-of-hooks treats any function starting with
"use" as a React hook. The helper is a pure switch — give it a name
that doesn't trip the rule.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 05:35:18 +02:00
Naiyuan Qing
434003d129 fix(my-issues): rename tab 3 label to include squads (MUL-2397) (#2830)
Tab 3's semantics were widened in #2829 to surface issues assigned to
either an owned agent OR a squad the user belongs to / leads. The label
still said "我的智能体" / "My Agents", which under-described the new
scope. Rename to "我的智能体和小队" / "My Agents and Squads" so the tab
title matches what it filters.

Locale-only change. Filter logic, SQL, and other tabs untouched.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 11:07:34 +08:00
Naiyuan Qing
93153d08b7 feat(my-issues): cover squad assignees via involves_user_id (MUL-2397) (#2829)
Re-introduces the `involves_user_id` filter on the issues list / open-list /
count / grouped paths, but with the semantics nailed down for the second time
around: tab 3 surfaces issues whose assignee is an *indirect* extension of the
user (owned agent, or a squad they're a human member of / lead via owned agent
/ have an owned agent inside) — and explicitly NOT direct member assignment,
which is tab 1's meaning.

- server/pkg/db/queries/issue.sql: 4-branch filter on ListIssues /
  ListOpenIssues / CountIssues. Each subquery clamps workspace_id because
  issue.assignee_id is polymorphic with no FK. Leader resolution reads
  squad.leader_id directly, not the squad_member copy row (squad.go ignores
  errors when seeding that copy, so it can be missing). FindActiveDuplicateIssue
  switched from positional $2/$3/$4 to named sqlc.arg() — pure hygiene so the
  generated struct field names don't drift when new nargs are added.
- server/internal/handler/issue.go: parse involves_user_id and plumb it into
  the three sqlc params; ListGroupedIssues (hand-written dynamic SQL) gets a
  mirrored 4-branch fragment, no shortcut.
- packages/core: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter /
  api.listIssues / api.listGroupedIssues all carry the new param through.
- packages/views/my-issues: tab 3 switches from client-side agent-fanout to
  involves_user_id=user.id. agentListOptions import and the myAgentIds memo
  go away.
- server/internal/handler/issue_involves_test.go: 13 integration tests cover
  every branch (positive + cross-workspace negatives) plus the critical
  ExcludesDirectMemberAssignee negative on BOTH the sqlc and the grouped paths,
  locking tab 3 ∩ tab 1 = ∅.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 10:37:38 +08:00
Jiayuan Zhang
35fc318d68 feat(runtimes): weekly usage dimension + tz-aware aggregation (MUL-2382) (#2822)
* feat(runtimes): weekly usage dimension + tz-aware aggregation (MUL-2382)

Adds a Weekly view to the runtime Usage chart alongside Daily and Hourly,
backed by `aggregateByWeek` on the existing 180-day daily cache (no new
endpoint). Weeks are ISO 8601 Mon–Sun; the in-progress week is rendered at
half opacity and tooltip-labelled "partial · N / 7 days".

Side effects called out in the RFC:

- `sliceWindow` now reads "today" in the runtime's IANA timezone, fixing a
  one-day drift at the window edge when the browser and runtime sit in
  different time zones.
- ActivityHeatmap rows are reordered Mon → Sun to match the rest of the
  Weekly aggregation; "today" is computed in runtime tz so the grid's
  trailing column lines up with the daily rows the backend buckets.

Dimension / period coupling: switching dimension resets the period to that
dimension's default when the active value isn't in its allowed set
(Hourly 7/30, Daily 7/30/90, Weekly 30/90/180).

Unit tests cover weekStart / addDays / tz-aware today, the sliceWindow
boundary, and aggregateByWeek's partial-week math.

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

* fix(runtimes): weekly chart shows trailing calendar weeks (MUL-2382)

aggregateByWeek built one bucket per week-with-data, and the caller
took the last N buckets. With sparse data — old populated weeks plus
empty stretches near today — the slice surfaced the old weeks instead
of the trailing in-window calendar weeks the user selected.

Now aggregateByWeek takes weekCount and emits exactly that many
trailing calendar weeks anchored at today's week in the runtime tz.
Buckets are pre-zeroed so empty in-range weeks render as empty bars;
rows outside the window are dropped.

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

* feat(usage): drop Hourly dim + add Daily/Weekly to workspace dashboard (MUL-2382)

- Remove Hourly from the runtime usage WHEN-chart: segmented control is
  now Daily / Weekly. Drop the HourlyActivityChart component,
  aggregateCostByHour helper, byHour query subscription, and the
  when_tab_hourly i18n key.
- Add the same Daily / Weekly dimension toggle to the workspace-level
  Usage page (dashboard-page.tsx). Time-range linkage matches the runtime
  page: Daily allows 7/30/90 (default 30), Weekly allows 30/90/180
  (default 90); switching dimensions resets `days` when the current value
  isn't in the new dimension's set.
- Reuse `aggregateByWeek` from runtimes/utils for cost / tokens
  (signature relaxed to accept the wider DashboardUsageDaily shape).
  Add `aggregateWeeklyTime` / `aggregateWeeklyTasks` in dashboard/utils
  with identical pre-zeroed trailing-week semantics. Workspace dashboard
  uses the user-chosen timezone (existing TimezoneSelect) as the
  week-boundary tz; runtime page continues to use the runtime's IANA tz.
- New `WeeklyTimeChart` / `WeeklyTasksChart` mirror their daily
  counterparts plus partial-week half-opacity bars and rangeLabel
  tooltips, matching the existing Weekly cost / tokens charts.
- Tests: drop hourly-related setup; add weekly run-time / tasks coverage
  asserting pre-zeroed trailing buckets and the same MUL-2382 sparse
  window-scoping regression we caught on the runtime side.

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

* fix(usage): correct workspace Weekly window + lock tz to UTC (MUL-2382)

Two blocking correctness bugs from Emacs's PR #2822 review:

1. The Weekly chart paints `ceil(days/7)` trailing calendar weeks but the
   API was still asked for exactly `days`. Worst case (today = Sunday on a
   30D request) the leftmost Monday sits 34 days back, so the first week's
   bucket was silently truncated. Over-fetch the per-date queries to
   `weekCount * 7` days when Weekly is active; per-agent rollups stay at
   `days` so the KPI / leaderboard labels keep their advertised window.
   Daily-aggregation surfaces (cost/tokens/time/tasks KPIs and the Daily
   chart) re-scope the over-fetched rows back to `days` so the labels
   stay consistent.

2. The backend dashboard rollup buckets data by UTC `bucket_date` (and the
   raw fallback queries by `DATE(tu.created_at)`, also UTC), but the
   frontend was driving Weekly boundaries from the user-chosen
   `TimezoneSelect`. Near midnight UTC that put cross-boundary rows into
   the wrong calendar week. Lock workspace Weekly to UTC and remove the
   timezone picker from this page; the runtime detail page keeps its own
   `runtime.timezone`-anchored aggregation, which is consistent because
   its rollup is materialized in that runtime's tz.

Verification: pnpm --filter @multica/views test (636 passed),
typecheck clean, lint 0 errors / 13 pre-existing warnings.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 04:24:46 +02:00
Naiyuan Qing
5476e7678d Revert "feat(my-issues): cover squad assignees via involves_user_id (MUL-2364…" (#2828)
This reverts commit 3c510c31ed.
2026-05-19 09:31:43 +08:00
Anderson Shindy Oki
e65c0889b9 feat: Add squad page responsive layout (#2826) 2026-05-19 09:18:30 +08:00
Naiyuan Qing
8db354f721 feat(editor): add open-in-new-tab to HTML attachment full-screen modal (#2827)
The inline HtmlAttachmentPreview toolbar carries an "Open in new tab"
button that routes to /{slug}/attachments/{id}/preview. The full-screen
AttachmentPreviewModal was missing the same affordance, so users who
maximized an HTML preview lost the ability to pop it into its own tab.

Mirror the gating exactly: show when kind === 'html' && slug &&
attachmentId. Other PreviewKinds keep the existing header (Download +
Close) — they don't have a corresponding full-page route.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 09:06:35 +08:00
Naiyuan Qing
3c510c31ed feat(my-issues): cover squad assignees via involves_user_id (MUL-2364) (#2801)
* feat(my-issues): cover squad assignees via involves_user_id (MUL-2364)

The "My Agents" tab on /my-issues only resolved agents owned by the
caller, so issues assigned to squads (member, leader, or agent-member of
mine) never surfaced. This added a UNION-based involves_user_id filter
that the backend expands to "me + agents I own + squads I relate to" in
a single query.

- SQL: ListIssues / ListOpenIssues / CountIssues accept narg
  involves_user_id and OR a workspace-scoped 3-branch UNION on the
  squad assignee subquery. Leader is sourced from canonical
  squad.leader_id (not the best-effort squad_member copy row whose
  AddSquadMember error is dropped in squad.go:177-188 and :259-263).
- Handler: parses involves_user_id via parseUUIDOrBadRequest, plumbs
  into all three list params, and mirrors the same UNION fragment into
  the grouped dynamic SQL path.
- Frontend: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter
  gain involves_user_id; api client forwards it to the querystring.
- My Issues page: "agents" scope now passes involves_user_id instead of
  fanning out owned-agent IDs client-side. Tab label widens to
  "我的智能体 / 小队" / "My Agents / Squads".
- Tests: Go suite covers all three squad relations including the
  canonical-leader-without-squad_member-copy variant, cross-workspace
  isolation for agent / leader / squad_member branches, combination
  with creator_id, and the malformed-UUID 400 path. Client test pins
  the involves_user_id querystring wiring for both list endpoints.

The FindActiveDuplicateIssue query gets explicit sqlc.arg() names so
sqlc regeneration keeps the existing struct field names regardless of
the local sqlc version (no behavior change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(my-issues): tighten cross-workspace negatives for involves_user_id UNION

Cross-workspace negative tests previously put both the foreign actor and the
foreign issue in the foreign workspace, so the outer i.workspace_id = $1
already excluded the row before the UNION branches were exercised. Stripping
a.workspace_id = $1 / s.workspace_id = $1 from any of the UNION subqueries
would not have failed the tests.

Rewrite the three existing negative cases to seed the issue in
testWorkspaceID with a polymorphic assignee_id pointing at a foreign-workspace
agent or squad (issue.assignee_id has no FK per migrations/001_init.up.sql:61).
Now each UNION branch must enforce its own workspace scoping for the issue to
stay out of the result.

Also add ExcludesOtherWorkspaceSquadAgentMember: the squad_member.agent UNION
branch had only positive coverage; this test pins that s.workspace_id = $1
and a.workspace_id = $1 must both hold there too.

Verified by mutation: stripping the workspace clause from each branch makes
the corresponding test fail.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 09:01:51 +08:00
Jiayuan Zhang
54f884ebc8 docs(runtimes): add install-agent-runtime page and link from onboarding empty state (#2825)
New docs page covering install pointers, binary names the daemon scans
for, and basic auth notes for all 11 supported AI coding tools. EN +
zh-Hans, registered under "How agents run" in the docs sidebar.

The onboarding "no agent runtime found" empty state now shows an
"Install an agent runtime →" link that opens the new doc, so users have
a discoverable path beyond "skip" and "join waitlist".

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 02:00:31 +08:00
Jiayuan Zhang
e0a6a39a47 feat(agents): list-only tasks panel with issue search (MUL-2391) (#2820)
* feat(agents): list-only tasks panel with issue search (MUL-2391)

Replace the agent detail tasks view-mode toggle with a fixed list view and
add a search bar that filters by issue title, identifier, or pinyin.

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

* fix(actor-issues): only show search empty state when searching

Previously the panel rendered the search empty state whenever the
filtered issue list was empty, which masked ListView's own status-based
empty states when status/priority/assignee/project/label filters
narrowed the list to 0. Now search_empty only renders when
`search.trim()` is non-empty and results are 0; otherwise ListView
takes over and shows its native empty states.

Refs MUL-2391

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:44:21 +02:00
Bohan Jiang
6f5fbb7813 feat(comments): thread-aware list with composite cursor (MUL-2340) (#2787)
* feat(comments): thread-aware list with composite cursor (MUL-2340)

Adds three optional query params to GET /api/issues/{id}/comments and the
matching `multica issue comment list` flags:

- `thread=<comment-uuid>` resolves the anchor to the thread root via a
  recursive CTE (defends against any future nested replies) and returns
  root + all descendants chronologically. Anchor can be any comment in
  the thread, root or reply.
- `recent=<N>` returns the newest N comments for the issue, ordered
  chronologically in the response.
- `before=<RFC3339>` + `before-id=<uuid>` form a composite cursor for
  stable pagination of `recent`. Both must be set together; a
  timestamp-only cursor is rejected because ties on `created_at` would
  let the existing `(created_at ASC, id ASC)` total order skip or
  duplicate rows across pages.

Flag combination rules: `thread` is exclusive with `recent` and the
cursor; both may combine with `since`. Server and CLI enforce the same
matrix; the CLI fails fast locally so callers don't pay for a 400
round-trip.

Default behaviour (no params) is unchanged — full chronological dump
capped at commentHardCap — so the desktop UI and existing `--since`
polling are untouched. Agent prompt updates land in a follow-up PR so
the new CLI capabilities ship and bake first.

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

* fix(comments): reject cursor without recent and align CLI/server on invalid --recent (MUL-2340)

Elon's PR #2787 second review flagged two gaps in the flag combination
matrix:

- server: GET /comments?before=...&before_id=... without `recent` was
  silently dropped by fetchCommentsForList (RecentN=0 fell through to
  the default / since path), so callers got the full timeline instead
  of the documented "before X" semantics. Now returns 400.
- CLI: --recent 0 / --recent -3 were collapsed with "flag not passed"
  by `recent > 0`, so an explicit invalid value silently fell back to
  the default list. Switched to Flags().Changed("recent") so explicit
  non-positive values fail loudly. Also enforces that --before /
  --before-id only appear with explicit --recent (mirrors the new
  server-side rule).

Tests:
- server flag matrix gains `before + before_id without recent → 400`.
- CLI gains TestRunIssueCommentListFlagGuards covering `--recent 0`,
  `--recent -3`, cursor-without-recent, and the thread/recent
  exclusivity path under the new Changed()-based check. The mock
  server fatals if a request reaches /comments, proving the guards
  fire before any HTTP round-trip.

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

* feat(comments): make `recent` thread-grouped with a thread cursor (MUL-2340)

Bohan pushed back on the row-based `recent=N` shape: comments form a tree,
not a list, and the newest N rows can come from N unrelated threads, giving
the agent N disjoint conversational tails. Replace the row-based query with
a thread-grouped one before #2787 merges so we never ship the wrong shape:

- `recent=N` now returns the N most recently active threads (root + every
  descendant per thread). A thread's recency is MAX(created_at) across its
  whole subtree, so a stale-but-recently-replied thread outranks an old
  quiet one — exactly the property row-recent loses.
- The cursor is now a *thread* cursor: `before` = a thread's
  last_activity_at, `before_id` = its root comment id. The pair walks
  threads strictly less recent than the page's oldest-active thread. The
  cursor surfaces via `X-Multica-Next-Before` / `X-Multica-Next-Before-Id`
  response headers (empty when there are no older threads); the CLI
  forwards the same pair to stderr after listing.
- Row-based `recent` is gone — there is no internal caller and the prompt
  update has not shipped yet, so there is no compat surface to preserve.
- Response body shape unchanged (flat JSON array, chronological). Default
  and `--since` paths untouched. Desktop UI keeps working.

Tests:
- recent=1 returns the freshest-active thread fully; recent=2 returns both
  with the older-active thread first (oldest-active → freshest tail).
- Stale-but-fresh: a thread whose root is older but has a fresh reply
  outranks a thread whose root is newer but quiet.
- Cursor headers emitted only on full pages; empty on the final page.
- Pagination walks threads root2 → root1 → empty, no skips/duplicates.
- Tie-break: three threads sharing last_activity_at paginate one-at-a-time
  using (last_activity_at, root_id) ordering — verifies the timestamp-only
  cursor failure mode is fixed for the thread case too.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 19:28:26 +08:00
Naiyuan Qing
baedc48f59 fix(editor): source-view highlight + HTML attachment open-in-new-tab (#2812)
* fix(editor): bump hast-util-to-html to v9 so lowlight output actually serializes

Source view of fenced ```html (and any other code block falling through to
the lowlight branch in ReadonlyContent) silently rendered as un-highlighted
escaped text. Root cause was a stale dep pin: `hast-util-to-html: ^4.0.1`
predates the package's ESM/named-export rewrite — v4 only exports a CJS
default function, so the `import { toHtml } from "hast-util-to-html"` in
code-block-static.tsx:19 and readonly-content.tsx:32 resolved to
`undefined` at runtime. The try/catch in both call sites caught the
"toHtml is not a function" throw and fell through to escapeHtml plain
text, so no `.hljs-*` spans ever made it to the DOM and the syntax-color
CSS added in #2808 had nothing to attach to.

Bumping to ^9.0.5 (matches the v9 line that lowlight@3 / remark / rehype
ship in the rest of the tree) makes the named `toHtml` export available
and source-view highlighting works.

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

* feat(editor): open HTML attachment in new tab + full-page preview route

Adds a third toolbar button to HtmlAttachmentPreview between Maximize and
Download: open the attachment in a new app tab (desktop) or browser tab
(web). The full-screen modal stays — they serve different scenarios:
modal for a quick "see it bigger" without leaving the issue context,
new-tab when the user wants to keep the rendered HTML around while
working on something else.

Components:
- New workspace path: `/{slug}/attachments/{id}/preview?name={filename}`.
  Lives outside the (dashboard) group on web so the iframe gets the full
  viewport — sidebar would defeat the point. Desktop registers the route
  inside `WorkspaceRouteLayout` so workspace context resolution still
  runs (no slug → no path is built).
- `packages/views/attachments/attachment-preview-page.tsx`: shared full-
  page view that reuses `useAttachmentHtmlText` for the iframe srcDoc.
  Sandbox stays `allow-scripts` (no allow-same-origin) — same security
  posture as the inline preview.
- `HtmlAttachmentPreview`: adds Open-in-new-tab button. Routes through
  `useNavigation().openInNewTab` when available (desktop), falls back to
  `window.open(getShareableUrl(path))` on web. Button is hidden when no
  workspace slug is in scope (shouldn't happen in practice, but the
  shared component must not throw outside a workspace route).

Tests cover: desktop openInNewTab call args, web window.open fallback,
and that the failure-mode toolbar still surfaces all three actions.

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

* fix(editor): drop now-stale @ts-expect-error on hast-util-to-html imports

v9 ships bundled type declarations, so the directives added for v4 trigger
TS2578 ("Unused '@ts-expect-error' directive") on CI typecheck.

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-05-18 19:09:45 +08:00
Boynn
933f417dac fix(views): clear manual draft when packing into agent prompt (#2370)
When alternately switching between manual and agent modes in the create-issue
dialog, the title and description were being duplicated and accumulated on
every round-trip. Root cause: manual→agent packed title+description into the
agent prompt but left them in the shared useIssueDraftStore; the subsequent
agent→manual wrote the agent markdown into draft.description while the stale
draft.title persisted, so the remounted manual panel surfaced both.

Clear title/description from the shared draft at the moment they move into
the agent representation, so round-trips can't layer stale manual state on
top of prompt-as-description.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:50:08 +08:00
Naiyuan Qing
e6cf5a6eca fix(editor): highlight HTML source view + drop misplaced Copy on attachments (#2808)
Two issues from #2790's HTML inline preview work:

1. HTML source view rendered as default-colored text. lowlight emits
   `.hljs-tag` / `.hljs-name` for `<...>` brackets and element names, but
   content-editor.css only styled the keyword / string / attr / etc.
   classes — so toggling an inline ```html``` block to "source" showed
   attributes colored and everything else plain. Adds the two missing
   classes in light + dark.

2. HtmlAttachmentPreview carried a "Copy code" button. An HTML attachment
   is a file (view + download), not an inline source snippet. The inline
   ```html``` fenced block (HtmlBlockPreview) is where reading / copying
   source belongs. Drops the button, its state, and the useAttachmentHtmlText
   `canCopy` branch — the hook is still needed for the iframe srcDoc.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:27:28 +08:00
Qi Yijiazhen
d9ae891064 fix(avatar): stop bg-muted bleeding through transparent images (#2670)
ActorAvatar applies bg-muted on its container regardless of whether
an image is loaded, so transparent regions of PNG/SVG avatars reveal
the grey placeholder. agent-detail-inspector also wraps ActorAvatar
in an outer bg-muted div, layering a second grey square.

Make bg-muted conditional on the fallback state in ActorAvatar, and
drop the redundant bg-muted from avatar-picker's image-loaded branch
and the two inspector wrappers. Empty-state placeholders unchanged.
2026-05-18 18:23:46 +08:00
Bohan Jiang
ffba2607aa fix(daemon): default auto-update off for self-host instances (MUL-2381) (#2807)
A self-host operator running a fork of Multica with their own patches would
have their daemon silently upgraded to the upstream GitHub release, clobbering
the fork. Self-host setups also routinely pin to an older server, so a fresh
CLI may no longer talk to it.

Flip the default: auto-update remains opt-in on api.multica.ai and defaults to
off on any other server URL. Either side can override via
MULTICA_DAEMON_AUTO_UPDATE.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:19:07 +08:00
Bohan Jiang
b97cc3cb6e fix(autopilots): align trash icon with action buttons in webhook trigger row (#2805)
The TriggerRow's outer flex uses `items-start`, which made sense back
when every trigger only had one row of content (label + maybe a cron
expression). Once #2774 added the URL action row to webhook triggers
(Copy + Rotate buttons sitting on a second line inside the inner column),
the trash button stayed pinned to the top-right of the outer flex — it
visibly floats above the URL action buttons instead of lining up with
them, which reads as a layout glitch.

Move the trash button into the URL action row for webhook triggers so
all three action buttons (Copy, Rotate, Delete) share one flex container
and align by construction. Schedule and API triggers — which have no
URL row — keep the trash button pinned top-right (their bodies are
short enough that the top corner reads as "the row's right end").

Extract a `deleteButton` const so the JSX isn't duplicated, and add the
existing `delete_dialog.confirm` i18n string as the title attribute for
consistency with the other action buttons (Copy / Rotate already have
hover titles).

No behavioural change — same click handler, same confirm dialog.
2026-05-18 18:16:45 +08:00
Multica Eve
b58ab2cc48 docs: remove reverted runtime changelog note (#2806)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:12:44 +08:00
Bohan Jiang
eabfb8f3d1 fix(autopilots): reject unknown {{...}} tokens in issue title template (MUL-2370) (#2799)
* fix(autopilots): reject unknown {{...}} tokens in issue title template (MUL-2370)

`--issue-title-template` (and the matching `issue_title_template` API
field) silently kept any placeholder other than `{{date}}` as a literal
string in the rendered issue title — `{{.TriggeredAt}}`, `{{trigger_id}}`,
`${date}`, etc. would all slip through `strings.ReplaceAll` unchanged
because the renderer only knew one token. The flag name and help text
("Template for issue titles (create_issue mode)") and the docs phrasing
("the title supports interpolation like `{{date}}`") both implied a
richer placeholder set existed.

Tightens the contract on three fronts:
- Reject any `{{...}}` token other than `{{date}}` at create/update time
  with `unknown template variable %q; supported: {{date}}` — turns the
  silent-on-trigger surprise into an explicit 400 the moment the user
  sets the template.
- Update CLI flag help on `autopilot create --issue-title-template` and
  `autopilot update --issue-title-template` to spell out that only
  `{{date}}` (UTC, YYYY-MM-DD) is interpolated.
- Update `apps/docs/content/docs/autopilots{,.zh}.mdx` to drop the
  "like `{{date}}`" phrasing for the single supported placeholder.

Adds service-layer tests covering `interpolateTemplate` (substitution,
empty-template fallback, no-placeholder verbatim) and
`ValidateIssueTitleTemplate` (accepts empty / plain / `{{date}}` /
`{{ date }}`; rejects Go-template, Mustache-style, future placeholders
like `{{datetime}}`, and templates that mix one valid and one invalid
token).

Expanding the placeholder set (`{{datetime}}`, `{{trigger_id}}`,
`{{trigger_source}}`) is tracked as a separate enhancement — those
need run/trigger context plumbed into the renderer, which is out of
scope for this bug fix.

Closes #2732

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

* fix(autopilots): render {{ date }} whitespace form too (MUL-2370)

Validator permitted {{ date }} but interpolateTemplate only matched the
exact string {{date}}, so a template that passed create/update could
still emit a literal {{ date }} at trigger time — re-introducing the
silent-literal behaviour the validator was meant to remove.

Route rendering through the same regex as validation so every accepted
form is also a substituted form. Cover {{ date }} substitution in
TestInterpolateTemplate.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:12:14 +08:00
Bohan Jiang
e8d4b9a0a2 revert: drop exec_command watchdog (#2779, #2786) (MUL-2337) (#2803)
* Revert "fix(codex): bump default exec_command stuck timeout to 3 minutes (#2786)"

This reverts commit 433cd1aaf5.

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

* Revert "feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337) (#2779)"

This reverts commit 60bae62622.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:08:07 +08:00
Bohan Jiang
fe1ccb19c9 Revert "MUL-2324 conditionally inject non-core rule blocks (#2771)" (#2802)
This reverts commit e8fb0efe3d.
2026-05-18 17:48:44 +08:00
Naiyuan Qing
5f1ced867c feat(editor): HTML attachments render like images (MUL-2345 v4) (#2798)
* feat(editor): HTML attachments render like images (MUL-2345 v4)

HTML attachments no longer wear the file-card chrome (icon + filename
row). They now render as a sandboxed iframe with a hover-revealed
right-top toolbar (Open / Download / Copy code), mirroring the image
attachment visual model.

- New HtmlAttachmentPreview owns the iframe + hover toolbar plus three
  states (loading / success / error). Failure mode keeps the toolbar
  pinned open and Open/Download enabled so the user is never stranded
  without an escape hatch — Copy code disables when the text body is
  unavailable.
- New AttachmentBlock thin dispatcher picks the renderer per kind:
  html + attachmentId + !uploading -> HtmlAttachmentPreview, else
  AttachmentCard. All three entry points (file-card NodeView, readonly
  file-card, standalone AttachmentList) call AttachmentBlock, so feature
  work on a new kind only touches one place.
- AttachmentCard collapses back to a pure file-card row UI: the inline
  HTML iframe branch (InlineHtmlIframe + inlineHtmlEnabled +
  showInlineHtml) is removed.
- AttachmentBlock added to the editor barrel export.

Sandbox/server-side defenses unchanged: sandbox="allow-scripts" (no
allow-same-origin), srcDoc, server still returns text/plain + nosniff
on the /content proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(editor): pin three entry points to AttachmentBlock HTML route (MUL-2345)

Reviewer flagged that the v4 dispatcher refactor only had tests on the
shared AttachmentBlock + HtmlAttachmentPreview; the three real call
sites at file-card.tsx:59, readonly-content.tsx:279, and
comment-card.tsx:152 had no regression coverage. Reverting any one
would silently lose the inline HTML iframe path — the exact MUL-2330
regression we're meant to be locking down.

Each new test renders the real entry point with an HTML+attachmentId
fixture and asserts the dispatched iframe (sandbox=allow-scripts,
srcdoc) shows up while the AttachmentCard chrome (filename row) does
not. FileCardView and AttachmentList are exported from their files for
direct rendering, mirroring the existing CodeBlockView test pattern.

Mutation-tested locally: temporarily flipping each site back to
<AttachmentCard> turns its corresponding test red.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:44:32 +08:00
Multica Eve
4d8b6ddb84 docs: add May 18 changelog entry (#2800)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:28:52 +08:00
Bohan Jiang
692570f41a fix(autopilots): contain Delivery dialog within viewport (#2788)
Two related overflow bugs in the Delivery detail dialog (the popover you
open from a webhook deliveries row, shipped in #2784) became obvious as
soon as a real webhook payload was exercised:

1. **Horizontal overflow: minified JSON pushed dialog off-screen.**
   `CodeBlock`'s `<pre>` uses `white-space: pre` (default for the tag),
   which means a single-line minified JSON body had intrinsic
   min-content equal to the whole line's width. The parent grid cell
   inherits the default `min-width: auto` (= min-content), so a long
   body propagated all the way up and blew DialogContent past its
   `max-w-2xl` cap. Headers rendered fine because they're
   pretty-printed JSON with real newlines.

   Fix: `min-w-0` on the CodeBlock wrapper so it can shrink below
   min-content, plus `whitespace-pre-wrap break-all` on the `<pre>` so
   long lines wrap (`break-all` is the only modifier that breaks
   mid-token, which a minified JSON body needs because it has no
   whitespace to break at).

2. **Vertical overflow: dialog grew past viewport.**
   `DialogContent` had no height cap. With Raw body + Headers +
   Response body + Replay button stacked vertically, anything beyond
   the screen edge (notably the Replay button) became unreachable.

   Fix: `max-h-[85vh] overflow-y-auto` on `DialogContent`.

Both fixes are CSS-only in one file; HMR verified.
2026-05-18 17:07:14 +08:00
Bohan Jiang
84d75cdd1e docs(self-host): reverse-proxy guidance for loopback-only ports (MUL-2360) (#2794)
* docs(self-host): explain loopback-only bindings + reverse proxy guidance (MUL-2360)

Follow-up to #2759, which bound all docker-compose published ports to
127.0.0.1. The self-host quickstart still told cross-machine users to
point their CLI at `http://<server-ip>:8080`, which no longer works
(and shouldn't — the default JWT_SECRET/Postgres creds must not be
reachable from the open internet).

- Add a Callout to step 1 explaining the loopback-only bindings and
  linking to the new reverse-proxy step.
- Split step 5 into 5a (same machine, defaults) and 5b (cross-machine),
  with a minimal Caddyfile that fronts both frontend and backend on a
  single hostname (including the `/ws` route with `flush_interval -1`).
  Switch the cross-machine `--server-url` example to `https://<domain>`.
- Mirror the changes in the Chinese quickstart.
- Add a header comment block to docker-compose.selfhost.yml so anyone
  reading the file directly understands why services don't show up on
  `0.0.0.0` and what to do about it.

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

* docs(self-host): use nginx highlighter for Caddyfile snippet

Shiki's default bundle does not include `caddy` / `caddyfile`, so
Vercel's `pnpm build` failed with:

  ShikiError: Language `caddy` is not included in this bundle.

Switch the code fence to `nginx`, which is in the default bundle and
gives near-identical visual highlighting for this snippet. No content
changes — the Caddyfile inside the block is untouched.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:00:31 +08:00
AdamQQQ
fab0671332 feat(skills): support multi-select bulk import in Copy from runtime (#2686)
- Multi-select UI for batch importing skills from a local runtime
- Server batch-dispatches up to 10 import requests per heartbeat cycle
- WS heartbeat now reads supports_batch_import from daemon payload
  instead of hardcoding true, so old daemons correctly fall back to
  one-at-a-time dispatch
- Raised server pending timeout to 3min and client poll timeout to 4min
  to accommodate daemons that pop only one import per 15s heartbeat

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 16:56:27 +08:00
Jiayuan Zhang
46c1e2c889 feat(squads): show member working status on squad detail page (#2768)
* feat(squads): show member working status on squad detail page

Add a new GET /api/squads/{id}/members/status endpoint that returns each
member's derived working/idle/offline/unstable status, the issues each
agent is currently running, and the last observed activity timestamp.
The Squad detail page's Members tab consumes this snapshot to render a
status pill and an active-issue link next to each agent, with live
refresh wired through the existing task/agent/daemon WS events.

Human members are returned with status=null so the UI can keep them in
the same list without implying a presence signal. Archived agents stay
in the response and surface as offline rather than being filtered out.

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

* fix(squads): address review feedback on member status endpoint

- i18n the "blocked" issue-status pill in squad members tab (was a
  bare literal that failed `i18next/no-literal-string` lint).
- Treat any dispatched/running task as working, even when its
  `agent_task_queue.issue_id` is NULL (chat / quick-create tasks).
  The agent slot is occupied regardless of whether we can render an
  issue link.
- Force `offline` for archived agents so they appear in the list
  but never look like they're still on duty, matching the RFC
  decision in MUL-2319.
- Include `workspaceKeys.squads` in the post-reconnect /
  workspace-switch bulk invalidation so members-status recovers
  after a disconnect during which task/runtime events were missed.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 10:35:18 +02:00
Zheng Li
c78bfbcf17 fix(skills): keep skill title input transparent in dark mode (#2710)
The skill name Input on the detail editor uses `bg-transparent px-0`
to render as flush, chrome-less text. The base Input component also
applies `dark:bg-input/30`, which Tailwind keeps because it lives in
the `dark:` variant. In dark mode this exposes a 30% white fill that
appears flush against the text — looking like missing left padding.

Add `dark:bg-transparent` to the className so the override wins in
both color modes.
2026-05-18 16:32:28 +08:00
Bohan Jiang
1796ef6dff fix(runtimes): prefer Local machine as default selection (MUL-2359) (#2792)
On desktop, localDaemonId is fetched async, so on first paint the only
machines available are remotes — the existing auto-select picks the
first remote, then sticks because subsequent renders see selectedMachineId
still in the list. Result: the local Mac never gets the default focus
even though it sorts first.

Re-evaluate the default on every machines change, preferring the local
section. Honor a user pick once it's been made.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:29:02 +08:00
Naiyuan Qing
ceb967aefa feat(editor): inline HTML attachment preview + ```html block render (MUL-2345) (#2790)
* feat(editor): inline HTML attachment preview + ```html block render (MUL-2345)

* attachment-preview-modal: switch HTML iframe sandbox from "" to
  "allow-scripts" so JS-driven chart libraries render. The opaque-origin
  iframe still cannot touch cookies, localStorage, parent state, or
  top-nav — only scripts run.
* New shared AttachmentCard wired into the three attachment surfaces
  (file-card NodeView, ReadonlyContent file-card branch, comment-card
  standalone AttachmentList). HTML attachments now render inline via a
  sandboxed iframe pulled through the existing /content proxy; other
  kinds keep the original chrome behavior.
* New HtmlBlockPreview for fenced ```html blocks in ReadonlyContent —
  default preview iframe, source/Copy toggle. Two-layer code+pre unwrap
  mirrors the Mermaid pattern; unwrap now matches on language-* class
  because react-markdown invokes pre before the code renderer runs.
* CodeBlockView (Tiptap NodeView) renders an iframe preview for
  language=html with a CSS-hidden toggle to the editable source — the
  <NodeViewContent as="code"/> mount must remain in the tree.
* Shared use-attachment-html-text hook keeps inline and modal HTML
  rendering on the same React Query cache.
* Vitest coverage: allow-scripts assertion, attachment-card kind
  branches, readonly HTML iframe + Mermaid unwrap regression, NodeView
  editable + preview/source toggle.

No backend changes; server-side text/plain + nosniff defense kept.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): tighten attachment preview and pre unwrap gates (MUL-2345)

Addresses Reviewer REQUEST CHANGES on PR #2790:

1. URL-only text/html attachment cards no longer surface a dead Eye
   button. `AttachmentCard` previously allowed preview when
   `previewableFromUrl=true` regardless of kind, but the modal's
   `tryOpen` rejects URL-only text kinds because the `/content` proxy
   is ID-keyed. Drop the `previewableFromUrl` prop and gate the
   no-attachmentId path strictly to URL-previewable media kinds
   (pdf/video/audio).

2. Readonly `pre` unwrap now uses exact class-token matching. The
   previous `className.includes("language-html")` check also fired
   on `language-htmlbars`, silently stripping its `<pre>` wrapper.
   Use `/(^|\s)language-(html|mermaid)(\s|$)/` so only the exact
   tokens unwrap.

Regression tests:
- `report.html + no attachmentId` asserts no Preview button.
- `pdf URL-only` asserts Preview button still appears.
- `htmlbars` / `mermaidx` fences keep their `<pre><code>` wrapper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:23:40 +08:00
Ayman Alkurdi
d04b00b32e fix(security): bind all services to loopback in docker-compose files (#2759)
The base docker-compose.yml bound postgres to 0.0.0.0:5432 and
docker-compose.selfhost.yml bound postgres/backend/frontend without
a host_ip prefix — defaulting to 0.0.0.0 on all interfaces.

On any VPS with a public IP, these services were reachable from the
internet. Docker bypasses UFW iptables chains by default, so host-
level firewall rules on these ports had no effect.

Fix: prefix every port binding with 127.0.0.1 so services are only
reachable from the host itself. This matches the documented
DATABASE_URL (which uses localhost) and does not break any legitimate
local dev or self-host workflow — connections from the host shell,
migration scripts, and the backend container (via Docker internal
network) all continue to work unchanged.
2026-05-18 16:14:41 +08:00
Bohan Jiang
a4a18605eb fix(desktop): handle Cmd/Ctrl +/-/0 zoom in main process (MUL-2354) (#2791)
The default Electron application menu's zoomIn/zoomOut roles do not fire
reliably on macOS — Cmd+= would zoom in but Cmd+- could not undo it, so
users got stuck at the zoomed-in level with no way back.

Move the shortcut into before-input-event so the same handler covers
every platform and every keyboard layout. preventDefault here blocks
both the renderer keydown and the menu accelerator, so there's no
double-zoom risk on macOS.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:12:03 +08:00
Multica Eve
dfe2a57361 fix(autopilots): allow duplicate create_issue runs (#2789)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:05:54 +08:00
LinYushen
6621231237 fix: improve search ranking and snippet support (MUL-2329)
Fixes MUL-2329
2026-05-18 15:45:06 +08:00
Bohan Jiang
433cd1aaf5 fix(codex): bump default exec_command stuck timeout to 3 minutes (#2786)
The watchdog fires on a "no progress" window, so the default mainly
matters for commands that go fully silent (no outputDelta). Bumping
from 2m → 3m leaves more headroom for legitimately slow silent
commands before treating them as a dropped function_call_output, at
a modest cost to recovery latency.

MUL-2337

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:30:05 +08:00
YYClaw
8cc48b1176 fix(ui): vertically center SelectItem content (#2782) 2026-05-18 15:28:00 +08:00
Anderson Shindy Oki
2d501322e9 fix: Squads page unable to scroll (#2764) 2026-05-18 15:19:16 +08:00
Bohan Jiang
60bae62622 feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337) (#2779)
* feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337)

Codex app-server can drop the second function_call_output when two
exec_command calls fan out in the same turn and both async-yield through
the yield_time_ms boundary (observed 2026-05-18, MUL-2334 — Trump Agent
wedged for 6+ min with no semantic activity events to drive any existing
timer). The model then waits forever for the missing output; only the
10-minute semantic inactivity timeout would eventually rescue the run.

Add a per-call watchdog in the codex client that tracks open
exec_command / commandExecution items by call_id and fails the turn
quickly (default 2 min, configurable via ExecOptions.ExecCommandStuckTimeout)
when one stays open without progress. outputDelta events reset the
per-call progress timestamp so long-running streaming commands aren't
flagged.

This is a daemon-side mitigation only — codex itself still has the
upstream race, but the daemon no longer burns the full inactivity budget
before the run is marked failed and a new run can recover.

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

* feat(codex): track legacy exec_command_output_delta in watchdog (MUL-2337)

Mirrors the raw v2 item/commandExecution/outputDelta refresh on the legacy
codex/event protocol so a long-running streaming exec doesn't get falsely
flagged as stuck after begin + 2 min.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:14:45 +08:00
Bohan Jiang
c328c402d8 feat(autopilots): webhook deliveries tab + replay button (MUL-2334) (#2784)
Wires the frontend onto the PR1 webhook delivery layer. Adds a Deliveries
section to the autopilot detail page that lists recent deliveries
(queued / dispatched / rejected / ignored / failed) with provider, event,
attempt count, and timestamp. Clicking a row opens a detail dialog with
raw body, headers subset, response body, signature status, and a Replay
button. Replay is disabled client-side for signature-invalid / rejected /
still-queued deliveries to mirror the server's 400.

Backend contract is locked behind a lenient zod schema via
parseWithFallback — unknown future status / signature_status values
degrade to a generic row instead of dropping the whole list.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:13:07 +08:00
Bohan Jiang
2323b72710 feat(autopilots): webhook delivery layer + idempotency/signature/replay (MUL-2334) [PR1] (#2774)
* feat(autopilots): webhook delivery layer + idempotency / signature / replay (MUL-2334)

Splits "inbound webhook receipt" from "autopilot run creation" so we can
record duplicate attempts, signature outcomes, and ignored/skipped
deliveries — and replay a delivery on demand. v1 ingress wrote straight
into autopilot_run.trigger_payload, which collapsed the two concerns and
left run_only autopilots vulnerable to provider retry storms.

Backend only (PR1). UI Deliveries tab follows in PR2.

Schema (migration 093):
  - autopilot_trigger.provider: 'generic' | 'github' (default 'generic').
  - autopilot_trigger.signing_secret: nullable plaintext (HMAC needs it
    cleartext; mirrors how webhook_token is stored).
  - webhook_delivery: one row per inbound POST. Carries raw_body,
    selected_headers, dedupe_key/source, signature_status,
    autopilot_run_id, replayed_from_delivery_id, response_status / body.
  - Partial unique index on (trigger_id, dedupe_key) excludes NULL and
    'rejected' rows, so a wrong-secret 401 does NOT permanently block a
    future retry with the same X-GitHub-Delivery once the operator fixes
    the secret.

Ingress flow (autopilot_webhook.go), persist-first + sync dispatch:
  1. IP rate limit -> 2. token lookup -> 3. token rate limit ->
  4. read raw body -> 5. autopilot/workspace cross-check ->
  6. normalize JSON (400 without persistence on parse failure) ->
  7. compute dedupe key + signature status ->
  8. INSERT delivery (status=queued). On (trigger_id, dedupe_key)
     unique-violation: bump attempt_count on existing row and return
     the original delivery_id + autopilot_run_id with 200 ->
  9. invalid/missing signature: UPDATE -> rejected, return 401 with
     delivery_id (no dispatch, not replayable) ->
 10. trigger disabled / autopilot paused/archived: UPDATE -> ignored,
     return 200 ->
 11. DispatchAutopilot synchronously, UPDATE -> dispatched/skipped/failed
     with autopilot_run_id and the response body we returned ->
 12. TouchAutopilotTriggerFiredAt and return 200.

No new long-running worker. A stale 'queued' row only happens if the
process dies between INSERT and UPDATE; that's a follow-up sweeper, not
this PR.

Authenticated API:
  - GET    /api/autopilots/{id}/deliveries (slim list)
  - GET    /api/autopilots/{id}/deliveries/{deliveryId} (with raw_body)
  - POST   /api/autopilots/{id}/deliveries/{deliveryId}/replay -> creates
    a new delivery row (replayed_from_delivery_id set), dispatches a
    new run, never collapses onto the original via dedupe.
  - PUT    /api/autopilots/{id}/triggers/{triggerId}/signing-secret
    Write-only; trigger response surfaces has_signing_secret +
    signing_secret_hint (last 4 chars), never the secret itself.

Signature verification reuses the GitHub-compatible
X-Hub-Signature-256: sha256=<hex(hmac(body, secret))> scheme; the
HMAC helper is constant-time. Invalid/missing signatures still count
against per-IP and per-token rate limits.

autopilot_run.trigger_payload is intentionally preserved — delivery
records the HTTP receipt; run records the normalized envelope handed
to the agent. They are two different views.

Tests (Postgres-backed):
  - delivery persistence on accept
  - dedupe via Idempotency-Key and X-GitHub-Delivery; run_only retry
    storm pin (3 retries -> 1 run)
  - invalid signature: 401 + rejected row + no run linkage
  - missing signature when secret configured: 401 + 'missing' state
  - valid signature dispatches
  - signing secret never echoed in trigger responses; hint shows last 4
  - min-length and clear-by-empty for signing secret PUT
  - replay creates a NEW delivery + new run; rejected deliveries cannot
    be replayed
  - list omits raw_body; detail includes it; cross-autopilot ID returns
    404 (workspace isolation defense in depth)
  - provider validation: unknown -> 400, github -> 201 round-trips
  - bad-signature stream still counts against per-token rate limit

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

* fix(autopilots): address PR review on webhook delivery layer (MUL-2334)

- Exclude `failed` from the (trigger_id, dedupe_key) partial unique index
  alongside `rejected`, so a transient ingress failure does not strand the
  provider's stable X-GitHub-Delivery / Idempotency-Key retry. Update the
  dedupe lookup to prefer non-terminal rows under the same predicate.
- Tighten delivery status enum: drop `skipped` from the CHECK constraint
  and from the handler. A run that was admission-skipped (e.g. runtime
  offline) is now recorded as delivery=`dispatched` linked to the
  skipped run, with the response payload carrying status=`skipped`.
  Source of truth for skipped-ness is autopilot_run.status, not the
  delivery row — keeps the Deliveries UI enum unambiguous.
- On dispatch error, link the (possibly non-nil) autopilot_run returned
  by DispatchAutopilot to the failed delivery so Deliveries UI can
  navigate to the run row for debugging.
- Slim list projection: ListWebhookDeliveriesByAutopilot no longer pulls
  raw_body / selected_headers / response_body — a 100-row page × 256 KiB
  would otherwise round-trip ~25 MiB from Postgres per Deliveries reload.
  Detail endpoint continues to return the full row.
- Fix backend CI: TestGetDelivery_ReturnsFullPayload now decodes the
  response and asserts on the parsed raw_body instead of substring-
  matching against an escaped JSON string; raise the test-suite default
  webhook rate limits in TestMain so the shared 192.0.2.1 IP bucket
  doesn't fill across the suite and leak 429s into unrelated tests.
- Add regression coverage for the dedupe-after-failure path.

cd server && go test ./... is green locally.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 14:59:40 +08:00
276 changed files with 21765 additions and 5558 deletions

View File

@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.

View File

@@ -112,6 +112,13 @@ CLOUDFRONT_DOMAIN=
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
# "8760h", "720h30m") or plain integer seconds.
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
# set a longer value to reduce re-authentication frequency.
# Note: longer TTL = longer exposure window if a cookie is leaked.
# AUTH_TOKEN_TTL=2592000
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080

View File

@@ -269,21 +269,45 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
## Workspaces
### Working with multiple workspaces
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
1. `--workspace-id <id>` flag on the command
2. `MULTICA_WORKSPACE_ID` environment variable
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
### List Workspaces
```bash
multica workspace list
multica workspace list --output json
```
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
The current default workspace is marked with `*`.
### Watch / Unwatch
### Show Current Workspace
```bash
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
multica workspace current
multica workspace current --output json
```
Prints the workspace that commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` would target.
### Switch Default Workspace
```bash
multica workspace switch <workspace-id>
multica workspace switch <slug>
```
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
### Get Details
```bash
@@ -508,6 +532,8 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).

View File

@@ -142,6 +142,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica workspace list` | List your workspaces (current is marked with `*`) |
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |

View File

@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
@@ -189,19 +190,13 @@ 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();
// Window-level keyboard shortcuts. Calling preventDefault here prevents
// both the renderer keydown AND the application menu accelerator, so
// anything we own here (reload-block, zoom) is the sole handler for
// that combination — no double-fire with the macOS default View menu.
mainWindow.webContents.on("before-input-event", (event, input) => {
if (handleAppShortcut(input, mainWindow!.webContents)) {
event.preventDefault();
}
});

View File

@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from "vitest";
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
function makeWc(initialLevel = 0) {
let level = initialLevel;
return {
getZoomLevel: vi.fn(() => level),
setZoomLevel: vi.fn((next: number) => {
level = next;
}),
currentLevel: () => level,
};
}
function key(
k: string,
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
): ShortcutInput {
return {
type: "keyDown",
key: k,
control: false,
meta: false,
...mods,
};
}
describe("handleAppShortcut — reload blocking", () => {
it("swallows Cmd+R on macOS", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("swallows Ctrl+R on Linux/Windows", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
});
it("swallows F5 regardless of modifier", () => {
const wc = makeWc();
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
});
it("ignores non-keyDown events", () => {
const wc = makeWc();
expect(
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
).toBe(false);
});
});
describe("handleAppShortcut — zoom in", () => {
it("zooms in on Cmd+= (unshifted)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Cmd++ (Shift+=)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Ctrl+= on non-mac", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("clamps zoom-in at the upper bound", () => {
const wc = makeWc(4.5);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(4.5);
});
});
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
it("zooms out on Cmd+- (unshifted)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Cmd+_ (Shift+-)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Ctrl+- on non-mac", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("undoes a prior Cmd+= so the user can return to 100%", () => {
const wc = makeWc(0);
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0.5);
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0);
});
it("clamps zoom-out at the lower bound", () => {
const wc = makeWc(-3);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(-3);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — reset zoom", () => {
it("resets to 0 on Cmd+0", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("resets to 0 on Ctrl+0", () => {
const wc = makeWc(-1.5);
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("ignores plain 0 without modifier", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — unrelated keys pass through", () => {
it("does not capture plain letters", () => {
const wc = makeWc();
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
import type { WebContents } from "electron";
// Shape of the input subset we read from Electron's `before-input-event`.
// Modeled as a structural type so the handler is unit-testable without a
// real Electron Input instance.
export type ShortcutInput = {
type: string;
key: string;
control: boolean;
meta: boolean;
};
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
// per step). Clamp to a range that keeps the UI legible — values outside
// this band turn the workspace into either confetti or a microfiche.
const ZOOM_STEP = 0.5;
const ZOOM_MIN = -3;
const ZOOM_MAX = 4.5;
/**
* Inspect a `before-input-event` key and apply (or block) the matching
* window-level shortcut. Returns `true` when the caller should call
* `event.preventDefault()` — that both swallows the renderer keydown and
* prevents the application menu accelerator from firing, so we don't
* double-trigger zoom on macOS where the default menu also binds these
* keys.
*
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
* default `Cmd+-` accelerator does not fire reliably across keyboard
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
* Handling the shortcuts here gives identical behavior on every platform
* and every layout.
*/
export function handleAppShortcut(
input: ShortcutInput,
webContents: ZoomTarget,
platform: NodeJS.Platform = process.platform,
): boolean {
if (input.type !== "keyDown") return false;
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
// (tabs, drafts, WS connections) with no URL bar to recover from.
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
return true;
}
if (!cmdOrCtrl) return false;
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
if (input.key === "=" || input.key === "+") {
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
if (input.key === "-" || input.key === "_") {
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + 0 → reset zoom to 100%.
if (input.key === "0") {
webContents.setZoomLevel(0);
return true;
}
return false;
}

View File

@@ -62,18 +62,25 @@ function WindowOverlayInner() {
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws) => {
onComplete={(ws, issueId) => {
close();
// Post-onboarding landing is always the workspace issues
// list. The welcome-issue flow moved into a dialog that
// renders on that page (StarterContentPrompt), so the
// flow doesn't need to thread a target issue id back here.
if (ws) {
// Runtime-connected onboarding lands on its single guide
// issue. Runtime-less exits still land on the issues list.
if (ws && issueId) {
push(paths.workspace(ws.slug).issueDetail(issueId));
} else if (ws) {
push(paths.workspace(ws.slug).issues());
} else {
push(paths.root());
}
}}
// Restart the bundled daemon when the user hits Refresh on
// Step 3. The daemon's PATH probe runs once at boot, so a
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
// up until the daemon is bounced.
onRuntimeRefresh={async () => {
await window.daemonAPI?.restart?.();
}}
/>
)}
</div>

View File

@@ -0,0 +1,16 @@
import { useParams, useSearchParams } from "react-router-dom";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
export function AttachmentPreviewRoute() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const filename = searchParams.get("name") ?? undefined;
if (!id) return null;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -13,6 +13,7 @@ import { SkillDetailPage } from "./pages/skill-detail-page";
import { AgentDetailPage } from "./pages/agent-detail-page";
import { MemberDetailPage } from "./pages/member-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { DashboardPage } from "@multica/views/dashboard";
@@ -160,6 +161,11 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Squad" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "attachments/:id/preview",
element: <AttachmentPreviewRoute />,
handle: { title: "Attachment" },
},
{
path: "usage",
element: <DashboardPage />,

View File

@@ -22,7 +22,7 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
An autopilot has two execution modes. **Start with "create issue" mode.**
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
## Run it on a schedule

View File

@@ -22,7 +22,7 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
## 让它按时间跑

View File

@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
### Issue keys
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
### Comments in code

View File

@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
### Issue 编号
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效
### 代码注释

View File

@@ -0,0 +1,169 @@
---
title: Install an agent runtime
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
This page is the install-side companion to:
- [Daemon and runtimes](/daemon-runtimes) — how detection works
- [AI coding tools matrix](/providers) — what each tool can and can't do (session resumption, MCP, model selection)
<Callout type="info">
The Multica server never sees your API keys or the tools themselves. Everything below — installation, authentication, model access — lives on your local machine. If something fails, it's almost always a local problem.
</Callout>
## Before you start
Two prerequisites apply to **every** tool below:
1. **The Multica daemon must be running.** Either run `multica daemon start` after installing the [Multica CLI](/cli), or use the [Multica desktop app](/desktop-app), which launches the daemon automatically. Without a running daemon there is nothing to detect tools.
2. **The tool's binary must be reachable on `PATH`.** The daemon shells out to each tool by name (see the **Daemon looks for** column in each section). If `which <name>` doesn't find it in your terminal, the daemon won't find it either. After installing, open a fresh terminal (or restart the daemon) so the new `PATH` entry is picked up.
After installing a tool, restart the daemon:
```bash
multica daemon restart
```
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 11 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
### Claude Code (Anthropic)
The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
| | |
|---|---|
| Daemon looks for | `claude` |
| Install | Follow the official guide at [claude.com/claude-code](https://www.claude.com/claude-code). The standard route is the npm package `@anthropic-ai/claude-code` (Node.js 18+ required). |
| Authentication | Run `claude` once and follow the in-CLI login flow, or set `ANTHROPIC_API_KEY`. |
| Notes | First-choice recommendation for new users. |
### Codex (OpenAI)
JSON-RPC 2.0 transport with finer-grained approval gates. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
| | |
|---|---|
| Daemon looks for | `codex` |
| Install | Follow the official guide at [github.com/openai/codex](https://github.com/openai/codex). The standard route is the npm package `@openai/codex`. |
| Authentication | `codex login` (browser-based) or `OPENAI_API_KEY`. |
### Cursor (Anysphere)
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
| | |
|---|---|
| Daemon looks for | `cursor-agent` |
| Install | Install the [Cursor editor](https://cursor.com/) and then the CLI per their docs at [docs.cursor.com](https://docs.cursor.com/). The binary name is `cursor-agent`, not `cursor`. |
| Authentication | Sign in through the Cursor editor; the CLI reuses that session. |
### GitHub Copilot
Model routing goes through your GitHub account entitlement — the tool doesn't pick a model itself; GitHub decides which model you get.
| | |
|---|---|
| Daemon looks for | `copilot` |
| Install | See GitHub's CLI docs at [github.com/github/copilot-cli](https://github.com/github/copilot-cli). |
| Authentication | Browser-based GitHub login through the CLI. |
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
### Gemini (Google)
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
| | |
|---|---|
| Daemon looks for | `gemini` |
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
### OpenCode (SST)
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
| | |
|---|---|
| Daemon looks for | `opencode` |
| Install | Follow the official guide at [opencode.ai](https://opencode.ai/) or the GitHub repo at [github.com/sst/opencode](https://github.com/sst/opencode). The typical route is the install script or the npm package. |
| Authentication | Configure your model provider(s) per OpenCode's docs (Anthropic, OpenAI, etc.). |
### Kiro CLI (Amazon)
ACP-over-stdio transport. Session resumption works through ACP `session/load`; skills are copied into `.kiro/skills/`.
| | |
|---|---|
| Daemon looks for | `kiro-cli` |
| Install | See the Kiro docs at [kiro.dev](https://kiro.dev/). The binary name is `kiro-cli`, not `kiro`. |
| Authentication | AWS-account-based; follow Kiro's own onboarding. |
### Kimi (Moonshot)
ACP-protocol agent, primarily aimed at the Chinese market. Skills live under `.kimi/skills/` (native discovery).
| | |
|---|---|
| Daemon looks for | `kimi` |
| Install | Follow the official guide at [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli). |
| Authentication | Moonshot API key, configured per the vendor's docs. |
### Hermes (Nous Research)
ACP-protocol agent (shares the transport with Kimi). Session resumption works. The skill injection path falls back to the generic `.agent_context/skills/` — verify your skills are loading before relying on them.
| | |
|---|---|
| Daemon looks for | `hermes` |
| Install | See Nous Research's repository at [github.com/NousResearch](https://github.com/NousResearch) for the latest CLI distribution. |
| Authentication | Per the vendor's docs. |
### OpenClaw
Open-source CLI agent orchestrator. **Model is bound at the agent layer** (`openclaw agents add --model`) — it can't be overridden per task, and you can't pass `--model` or `--system-prompt` from Multica.
| | |
|---|---|
| Daemon looks for | `openclaw` |
| Install | See the project at [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw) (community-maintained). |
| Authentication | Configure the underlying model provider per OpenClaw's docs. |
### Pi (Inflection AI)
Minimalist. **Session resumption is unusual** — the resume id is the path to a session file on disk, not a string id.
| | |
|---|---|
| Daemon looks for | `pi` |
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
| Authentication | Per the vendor's docs. |
## After installing
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.
## Troubleshooting
- **`which` finds the binary but the daemon doesn't.** The daemon was started with an older `PATH`. Restart it.
- **The binary exists but launching fails.** Run the tool's own `--version` or `--help` once from the terminal — most failures here are missing auth, expired tokens, or a Node.js / runtime mismatch.
- **The Runtimes page shows the row, but tasks fail immediately.** Check `multica daemon logs -f` while triggering a task. The daemon surfaces the tool's own error output.
For broader symptoms, see the [Troubleshooting guide](/troubleshooting).
## Next
- [Daemon and runtimes](/daemon-runtimes) — how detection, heartbeats, and offline handling work
- [AI coding tools matrix](/providers) — capability differences once a tool is connected
- [Creating and configuring agents](/agents-create) — pick a tool for your agent and start running tasks

View File

@@ -0,0 +1,169 @@
---
title: 安装一个 Agent 运行时
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
---
import { Callout } from "fumadocs-ui/components/callout";
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
这一页是装机的入口,和它配套的是:
- [守护进程与运行时](/zh/daemon-runtimes) — 检测是怎么工作的
- [AI 编程工具矩阵](/zh/providers) — 每款工具的能力差异会话续接、MCP、模型选择
<Callout type="info">
Multica 服务器从不接触你的 API key也不接触工具本身。下面这些操作 —— 安装、登录、模型访问 —— 全部发生在你本机。出问题几乎都是本地问题。
</Callout>
## 开始前
下面每一款工具都有两个共同前提:
1. **Multica 守护进程在运行。** 装完 [Multica CLI](/zh/cli) 后跑 `multica daemon start`;或者用 [Multica 桌面端](/zh/desktop-app),它启动时自动拉起守护进程。守护进程没起来,就没人去扫工具。
2. **工具的可执行文件在 `PATH` 上。** 守护进程通过名字 shell out 调起工具(见每一节里 **守护进程扫描** 那行的命令名)。终端里 `which <名字>` 找不到,守护进程也找不到。装完后打开新终端(或者重启守护进程),让新的 `PATH` 生效。
装完一款工具后,重启守护进程:
```bash
multica daemon restart
```
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
## 11 款支持的工具
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
### Claude CodeAnthropic
集成最完整的一款。会话续接好用MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
| | |
|---|---|
| 守护进程扫描 | `claude` |
| 安装 | 看官方指引 [claude.com/claude-code](https://www.claude.com/claude-code)。常见装法是 npm 包 `@anthropic-ai/claude-code`(需要 Node.js 18+)。 |
| 认证 | 跑一次 `claude`,跟着 CLI 里的登录流程走;或者设置 `ANTHROPIC_API_KEY`。 |
| 备注 | 新用户首选。 |
### CodexOpenAI
JSON-RPC 2.0 传输,审批粒度更细。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
| | |
|---|---|
| 守护进程扫描 | `codex` |
| 安装 | 看官方指引 [github.com/openai/codex](https://github.com/openai/codex)。常见装法是 npm 包 `@openai/codex`。 |
| 认证 | `codex login`(浏览器登录),或 `OPENAI_API_KEY`。 |
### CursorAnysphere
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id你传过去的续接 id 永远无效。
| | |
|---|---|
| 守护进程扫描 | `cursor-agent` |
| 安装 | 先装 [Cursor 编辑器](https://cursor.com/),再按 [docs.cursor.com](https://docs.cursor.com/) 的说明装 CLI。可执行文件叫 `cursor-agent`,不是 `cursor`。 |
| 认证 | 在 Cursor 编辑器里登录CLI 复用同一份会话。 |
### GitHub Copilot
模型走的是你 GitHub 账号的 entitlement —— 工具自己不挑模型GitHub 决定你拿到哪个模型。
| | |
|---|---|
| 守护进程扫描 | `copilot` |
| 安装 | 看 GitHub 的 CLI 文档 [github.com/github/copilot-cli](https://github.com/github/copilot-cli)。 |
| 认证 | CLI 里走 GitHub 浏览器登录。 |
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
### GeminiGoogle
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
| | |
|---|---|
| 守护进程扫描 | `gemini` |
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
### OpenCodeSST
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
| | |
|---|---|
| 守护进程扫描 | `opencode` |
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
| 认证 | 按 OpenCode 的文档配你自己的模型供应商Anthropic、OpenAI 等)。 |
### Kiro CLIAmazon
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作skills 拷到 `.kiro/skills/`。
| | |
|---|---|
| 守护进程扫描 | `kiro-cli` |
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
### KimiMoonshot
ACP 协议 agent主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
| | |
|---|---|
| 守护进程扫描 | `kimi` |
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
| 认证 | Moonshot API key按厂商文档配置。 |
### HermesNous Research
ACP 协议 agent和 Kimi 共享传输层。会话续接可用。Skill 注入用的是通用回退路径 `.agent_context/skills/` —— 用之前先验证 skills 真的被加载了。
| | |
|---|---|
| 守护进程扫描 | `hermes` |
| 安装 | 看 Nous Research 的仓库 [github.com/NousResearch](https://github.com/NousResearch) 获取最新 CLI。 |
| 认证 | 按厂商文档。 |
### OpenClaw
开源 CLI agent 编排器。**模型绑在 agent 层**`openclaw agents add --model`)—— 不能按任务覆盖,从 Multica 也传不了 `--model` / `--system-prompt`。
| | |
|---|---|
| 守护进程扫描 | `openclaw` |
| 安装 | 看项目 [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw)(社区维护)。 |
| 认证 | 按 OpenClaw 的文档配底层模型供应商。 |
### PiInflection AI
极简风格。**会话续接的方式不太一样** —— resume id 是磁盘上的会话文件路径,不是字符串 id。
| | |
|---|---|
| 守护进程扫描 | `pi` |
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
| 认证 | 按厂商文档。 |
## 装完之后
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
## 排错
- **`which` 找得到,但守护进程找不到。** 守护进程是用旧 `PATH` 启的,重启它。
- **可执行文件在,但启动就失败。** 在终端单独跑一次工具的 `--version` 或 `--help`绝大多数失败都是登录没做、token 过期、Node.js / 运行时版本不对。
- **Runtimes 页面看到行,但任务一跑就失败。** 一边触发任务一边跑 `multica daemon logs -f`。守护进程会把工具自己的报错原样吐出来。
更宽的症状看[排错指南](/zh/troubleshooting)。
## 接下来
- [守护进程与运行时](/zh/daemon-runtimes) — 检测、心跳、离线处理
- [AI 编程工具矩阵](/zh/providers) — 工具连上之后的能力差异
- [创建并配置智能体](/zh/agents-create) — 给你的 agent 挑一款工具,开始跑任务

View File

@@ -19,6 +19,7 @@
"squads",
"---How agents run---",
"daemon-runtimes",
"install-agent-runtime",
"tasks",
"providers",
"---Collaborating with agents---",

View File

@@ -45,6 +45,10 @@ Once it's up:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend**: [http://localhost:8080](http://localhost:8080)
<Callout type="info">
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
</Callout>
## 2. Important: keep production safety on
<Callout type="warning">
@@ -99,21 +103,53 @@ Open [http://localhost:3000](http://localhost:3000):
## 5. Point the CLI at your own server
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
```bash
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
```
### 5a. Same machine
If you're running everything on one local machine:
If the CLI and the server run on the same host, the defaults already work:
```bash
multica setup self-host
```
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
### 5b. Cross-machine: front with a reverse proxy
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
```bash
multica setup self-host \
--server-url https://<your-domain> \
--app-url https://<your-domain>
```
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
```nginx
multica.example.com {
# WebSocket route — must come before the catch-all
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# Everything else → frontend
reverse_proxy 127.0.0.1:3000
}
```
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
## 6. Create an agent + assign your first task

View File

@@ -44,6 +44,10 @@ make selfhost
- **前端**[http://localhost:3000](http://localhost:3000)
- **后端**[http://localhost:8080](http://localhost:8080)
<Callout type="info">
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
</Callout>
## 2. 重要:保持生产安全配置
<Callout type="warning">
@@ -98,21 +102,53 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
## 5. 连接命令行工具到你自己的 server
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
```bash
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
```
### 5a. 同一台机器
本地就是一台电脑跑整套的话
CLI 和 server 在同一台机器上时,默认参数就够用
```bash
multica setup self-host
```
默认连 `http://localhost:8080`backend+ `http://localhost:3000`frontend
会自动连 `http://localhost:8080`backend+ `http://localhost:3000`frontend,引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
### 5b. 跨机访问:用反向代理把服务挡在前面
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理Caddy / nginx / Cloudflare Tunnel由它终结 TLS再反代到 `127.0.0.1:8080`backend和 `127.0.0.1:3000`frontend。然后把 CLI 指到公开的 HTTPS 域名:
```bash
multica setup self-host \
--server-url https://<你的域名> \
--app-url https://<你的域名>
```
最小可用的 Caddyfile单域名同时挂前后端带 WebSocket 转发daemon 和网页端都依赖):
```nginx
multica.example.com {
# WebSocket 路由——必须在 catch-all 之前
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# 其它请求 → 前端
reverse_proxy 127.0.0.1:3000
}
```
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLShost 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
## 6. 创建智能体 + 分配第一个任务

View File

@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
<Callout type="warning">
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."

View File

@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
- **Slug短链标识符** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符
<Callout type="warning">
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
@@ -17,9 +17,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
* web (matching `WindowOverlay` on desktop); content is the shared
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
*
* On complete: if a workspace was just created, navigate into it;
* otherwise fall back to root (proxy / landing picks the user's first ws
* or bounces to onboarding if still zero).
* On complete: runtime-connected onboarding may provide a guide issue id;
* navigate there. Otherwise land on the workspace issues list, or root if
* the flow never produced a workspace.
*
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
* slot so the flow can render it inside the CLI dialog. The commands it
@@ -34,6 +34,14 @@ export default function OnboardingPage() {
...workspaceListOptions(),
enabled: !!user,
});
// The bootstrap path calls refreshMe() before returning, which flips
// hasOnboarded to true while the page is still mounted. Without this
// flag the guard below races onComplete: the guard's router.replace
// (issues list) can overtake onComplete's router.push (guide issue),
// dropping the user on the wrong destination. Marking the page as
// "completing" right before onComplete navigates keeps the guard
// silent for the in-flight transition.
const completingRef = useRef(false);
useEffect(() => {
if (isLoading || !user) {
@@ -41,6 +49,7 @@ export default function OnboardingPage() {
return;
}
if (!workspacesFetched) return;
if (completingRef.current) 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
@@ -62,12 +71,14 @@ export default function OnboardingPage() {
return (
<div className="h-full overflow-y-auto bg-background">
<OnboardingFlow
onComplete={(ws) => {
// No more firstIssueId handoff — the welcome issue is created
// inside the workspace via StarterContentPrompt, not during
// onboarding. Always land on the workspace issues list (or
// root if the flow never produced a workspace).
if (ws) {
onComplete={(ws, issueId) => {
// Runtime-connected onboarding now creates one focused
// onboarding issue. Skip/runtime-less exits still land on the
// workspace issues list.
completingRef.current = true;
if (ws && issueId) {
router.push(paths.workspace(ws.slug).issueDetail(issueId));
} else if (ws) {
router.push(paths.workspace(ws.slug).issues());
} else {
router.push(paths.root());

View File

@@ -0,0 +1,26 @@
"use client";
import { use } from "react";
import { useSearchParams } from "next/navigation";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
// Lives at /:slug/attachments/:id/preview — OUTSIDE the (dashboard) group on
// purpose. The dashboard layout adds a left sidebar + top chrome; this page
// wants the full viewport for the HTML iframe. Workspace resolution still
// happens in the parent [workspaceSlug] layout so useWorkspaceId() works.
export default function AttachmentPreviewWebPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const search = useSearchParams();
const filename = search.get("name") ?? undefined;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -284,6 +284,33 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.2",
date: "2026-05-18",
title:
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
changes: [],
features: [
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
],
improvements: [
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
"Search results are ranked more usefully and include better snippets",
],
fixes: [
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
"Squad pages scroll correctly and show which members are already working",
"Desktop zoom shortcuts work again across the common keyboard combinations",
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
],
},
{
version: "0.3.1",
date: "2026-05-15",

View File

@@ -284,6 +284,32 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.2",
date: "2026-05-18",
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
changes: [],
features: [
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
"Skills 支持从本地 runtime 批量复制到 workspace团队初始化更快",
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
],
improvements: [
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
],
fixes: [
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
"桌面端缩放快捷键在常见组合下恢复正常",
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
],
},
{
version: "0.3.1",
date: "2026-05-15",

View File

@@ -1,5 +1,13 @@
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
#
# Services bind to 127.0.0.1 only. For cross-machine or public access, front
# them with a reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates
# TLS and forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend).
# Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls
# (UFW/iptables) by default, so the raw ports would be exposed to the internet
# with the default JWT_SECRET and Postgres credentials. See:
# apps/docs/content/docs/self-host-quickstart.mdx
#
# Usage:
# cp .env.example .env
# # Edit .env — change JWT_SECRET at minimum
@@ -18,7 +26,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "${POSTGRES_PORT:-5432}:5432"
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
@@ -34,7 +42,7 @@ services:
postgres:
condition: service_healthy
ports:
- "${PORT:-8080}:8080"
- "127.0.0.1:${PORT:-8080}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
@@ -88,7 +96,7 @@ services:
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:3000"
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
restart: unless-stopped

View File

@@ -8,7 +8,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data

View File

@@ -0,0 +1,111 @@
import { test, expect } from "@playwright/test";
import { TestApiClient } from "./fixtures";
// Smoke test for Onboarding V2: verifies the new per-question flow
// renders and captures screenshots for review. Uses a unique email
// per run so the user is always a fresh, un-onboarded user landing
// on /onboarding.
const EMAIL = `onboarding-v2-${Date.now()}@localhost`;
const SHOTS_DIR = "/tmp/onboarding-v2-shots";
test.use({ viewport: { width: 1440, height: 900 } });
test("onboarding v2 — welcome → source → role → use_case (skip path)", async ({ page }) => {
const api = new TestApiClient();
await api.login(EMAIL, "OBv2 Tester");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => {
localStorage.setItem("multica_token", t);
}, token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
// 1. Welcome screen
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
await page.screenshot({ path: `${SHOTS_DIR}/01-welcome.png`, fullPage: false });
// Click Start exploring to advance to Source
await page.getByRole("button", { name: "Continue on web" }).click();
// 2. Source step
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
// Pick Friends/colleagues then click Continue to advance.
await page.getByRole("radio", { name: /Friends or colleagues/i }).click();
await page.getByRole("button", { name: "Continue" }).click();
// 3. Role step
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
// Skip role
await page.getByRole("button", { name: "Skip" }).click();
// 4. Use case step
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
// Pick ship_code then Continue → workspace step.
await page.getByRole("radio", { name: /Ship code with AI agents/i }).click();
await page.getByRole("button", { name: "Continue" }).click();
// 5. Workspace step (legacy)
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
await page.screenshot({ path: `${SHOTS_DIR}/05-workspace.png` });
});
test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
const api = new TestApiClient();
await api.login(`rage-skip-${Date.now()}@localhost`, "Rage Skipper");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Continue on web" }).click();
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
// Skip × 3
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "Skip" }).click();
// Lands on workspace step
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
await page.screenshot({ path: `${SHOTS_DIR}/06-after-rage-skip.png` });
});
test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context }) => {
await context.addCookies([
{ name: "multica-locale", value: "zh-Hans", url: "http://localhost:13442" },
]);
const api = new TestApiClient();
await api.login(`zh-${Date.now()}@localhost`, "中文用户");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.getByRole("button").first().click().catch(() => {});
// Source screen — Chinese question
await expect(page.getByText("你是从哪里了解到 Multica 的?")).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/07-source-zh.png` });
});

View File

@@ -3,3 +3,7 @@ export {
type AgentsScope,
type AgentsViewState,
} from "./view-store";
export {
useTranscriptViewStore,
type TranscriptSortDirection,
} from "./transcript-view-store";

View File

@@ -0,0 +1,22 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useTranscriptViewStore } from "./transcript-view-store";
beforeEach(() => {
useTranscriptViewStore.setState({ sortDirection: "chronological" });
});
describe("useTranscriptViewStore", () => {
it("defaults to chronological so existing readers see no behavior change", () => {
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
});
it("setSortDirection switches between the two known directions", () => {
const { setSortDirection } = useTranscriptViewStore.getState();
setSortDirection("newest_first");
expect(useTranscriptViewStore.getState().sortDirection).toBe("newest_first");
setSortDirection("chronological");
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
});
});

View File

@@ -0,0 +1,26 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../../platform/storage";
export type TranscriptSortDirection = "chronological" | "newest_first";
interface TranscriptViewState {
sortDirection: TranscriptSortDirection;
setSortDirection: (dir: TranscriptSortDirection) => void;
}
export const useTranscriptViewStore = create<TranscriptViewState>()(
persist(
(set) => ({
sortDirection: "chronological",
setSortDirection: (sortDirection) => set({ sortDirection }),
}),
{
name: "multica_transcript_view",
storage: createJSONStorage(() => defaultStorage),
partialize: (state) => ({ sortDirection: state.sortDirection }),
},
),
);

View File

@@ -23,6 +23,9 @@ import type {
AgentRunCount,
AgentRuntime,
InboxItem,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
IssueSubscriber,
Comment,
Reaction,
@@ -89,6 +92,8 @@ import type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
@@ -96,6 +101,7 @@ import type {
GitHubConnectResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -119,11 +125,19 @@ import {
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_SQUAD_MEMBER_STATUS_LIST,
EMPTY_TIMELINE_ENTRIES,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
EMPTY_WEBHOOK_DELIVERY,
GroupedIssuesResponseSchema,
ListIssuesResponseSchema,
ListWebhookDeliveriesResponseSchema,
OnboardingNoRuntimeBootstrapResponseSchema,
OnboardingRuntimeBootstrapResponseSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
WebhookDeliveryResponseSchema,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -151,6 +165,38 @@ export interface LoginResponse {
user: User;
}
export interface OnboardingRuntimeBootstrapResponse {
workspace_id: string;
agent_id: string;
issue_id: string;
}
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
OnboardingRuntimeBootstrapResponse = {
workspace_id: "",
agent_id: "",
issue_id: "",
};
export interface OnboardingNoRuntimeBootstrapResponse {
workspace_id: string;
issue_id: string;
}
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
OnboardingNoRuntimeBootstrapResponse = {
workspace_id: "",
issue_id: "",
};
// Serialize the inbox `scope` filter into a `?scope=me,my_agent` query
// fragment. The server rejects empty arrays, so callers must skip the bulk
// request entirely when no chip is selected (RFC v3 §E.1, mode=empty).
function inboxScopeQuery(scope?: InboxFilterScope[] | null): string {
if (!scope || scope.length === 0) return "";
return `?scope=${encodeURIComponent(scope.join(","))}`;
}
// --- Starter content (post-onboarding import) -----------------------------
// Shape mirrors the Go request/response in handler/onboarding.go.
//
@@ -405,6 +451,43 @@ export class ApiClient {
});
}
async bootstrapOnboardingRuntime(payload: {
workspace_id: string;
runtime_id: string;
}): Promise<OnboardingRuntimeBootstrapResponse> {
const raw = await this.fetch<unknown>(
"/api/me/onboarding/runtime-bootstrap",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return parseWithFallback(
raw,
OnboardingRuntimeBootstrapResponseSchema,
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
);
}
async bootstrapOnboardingNoRuntime(payload: {
workspace_id: string;
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
const raw = await this.fetch<unknown>(
"/api/me/onboarding/no-runtime-bootstrap",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return parseWithFallback(
raw,
OnboardingNoRuntimeBootstrapResponseSchema,
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
);
}
async joinCloudWaitlist(payload: {
email: string;
reason?: string;
@@ -471,6 +554,7 @@ export class ApiClient {
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params?.creator_id) search.set("creator_id", params.creator_id);
if (params?.project_id) search.set("project_id", params.project_id);
if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
if (params?.open_only) search.set("open_only", "true");
const path = `/api/issues?${search}`;
const raw = await this.fetch<unknown>(path);
@@ -491,6 +575,7 @@ export class ApiClient {
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params.creator_id) search.set("creator_id", params.creator_id);
if (params.project_id) search.set("project_id", params.project_id);
if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id);
if (params.assignee_filters?.length) {
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
@@ -847,12 +932,11 @@ export class ApiClient {
// ---------------------------------------------------------------------------
async getDashboardUsageDaily(
params: { days?: number; project_id?: string | null; squad_id?: string | null },
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.squad_id) search.set("squad_id", params.squad_id);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
return parseWithFallback<DashboardUsageDaily[]>(
raw,
@@ -863,12 +947,11 @@ export class ApiClient {
}
async getDashboardUsageByAgent(
params: { days?: number; project_id?: string | null; squad_id?: string | null },
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageByAgent[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.squad_id) search.set("squad_id", params.squad_id);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
return parseWithFallback<DashboardUsageByAgent[]>(
raw,
@@ -879,12 +962,11 @@ export class ApiClient {
}
async getDashboardAgentRunTime(
params: { days?: number; project_id?: string | null; squad_id?: string | null },
params: { days?: number; project_id?: string | null },
): Promise<DashboardAgentRunTime[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.squad_id) search.set("squad_id", params.squad_id);
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
return parseWithFallback<DashboardAgentRunTime[]>(
raw,
@@ -895,12 +977,11 @@ export class ApiClient {
}
async getDashboardRunTimeDaily(
params: { days?: number; project_id?: string | null; squad_id?: string | null },
params: { days?: number; project_id?: string | null },
): Promise<DashboardRunTimeDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
if (params.squad_id) search.set("squad_id", params.squad_id);
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
return parseWithFallback<DashboardRunTimeDaily[]>(
raw,
@@ -1025,8 +1106,8 @@ export class ApiClient {
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
return this.fetch("/api/inbox");
async listInbox(scope?: InboxFilterScope[]): Promise<InboxItem[]> {
return this.fetch(`/api/inbox${inboxScopeQuery(scope)}`);
}
async markInboxRead(id: string): Promise<InboxItem> {
@@ -1041,20 +1122,28 @@ export class ApiClient {
return this.fetch("/api/inbox/unread-count");
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
async getInboxScopeCounts(): Promise<InboxScopeCounts> {
return this.fetch("/api/inbox/scope-counts");
}
async archiveAllInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all", { method: "POST" });
async getInboxResourceAvailability(): Promise<InboxResourceAvailability> {
return this.fetch("/api/inbox/resource-availability");
}
async archiveAllReadInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
async markAllInboxRead(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/mark-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveCompletedInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
async archiveAllInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-all${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveAllReadInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveCompletedInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-completed${inboxScopeQuery(scope)}`, { method: "POST" });
}
// Notification preferences
@@ -1097,7 +1186,7 @@ export class ApiClient {
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
@@ -1542,6 +1631,17 @@ export class ApiClient {
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
}
// Per-squad members status snapshot: one row per member with derived
// working/idle/offline/unstable plus the issues each agent is currently
// running. Parsed with a lenient schema so a new server-side status
// value or extra field can't white-screen the Squad page (#2143).
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
endpoint: "GET /api/squads/:id/members/status",
}) as SquadMemberStatusListResponse;
}
// Autopilots
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();
@@ -1617,6 +1717,64 @@ export class ApiClient {
);
}
// Webhook deliveries — list is slim (no raw_body / selected_headers /
// response_body); detail returns the full row. Both responses are parsed
// through a lenient schema so an unknown server-side `status` /
// `signature_status` value degrades to a generic row instead of dropping
// the whole list.
async listAutopilotDeliveries(
autopilotId: string,
params?: { limit?: number; offset?: number },
): Promise<ListWebhookDeliveriesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", params.limit.toString());
if (params?.offset) search.set("offset", params.offset.toString());
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries?${search}`,
);
return parseWithFallback(
raw,
ListWebhookDeliveriesResponseSchema,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
{ endpoint: "GET /api/autopilots/:id/deliveries" },
);
}
async getAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
);
}
// Replay creates a NEW delivery row referencing the original via
// `replayed_from_delivery_id`. Server rejects replays of
// signature-invalid / rejected deliveries with 400 — the UI keeps the
// button disabled for those rows, but the server is the source of truth.
async replayAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
{ method: "POST" },
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
);
}
// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);

View File

@@ -198,6 +198,68 @@ describe("ApiClient schema fallback", () => {
});
});
describe("listAutopilotDeliveries", () => {
it("falls back to an empty list when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res).toEqual({ deliveries: [], total: 0 });
});
it("falls back to an empty list when `deliveries` is not an array", async () => {
stubFetchJson({ deliveries: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res).toEqual({ deliveries: [], total: 0 });
});
it("accepts an unknown future status value rather than dropping the row", async () => {
// Server-side enum drift (e.g. new `quarantined` state). The list
// must still surface the row; downstream UI code's `default` arm
// handles unknown values with a generic visual.
stubFetchJson({
deliveries: [
{
id: "d-1",
workspace_id: "ws-1",
autopilot_id: "ap-1",
trigger_id: "t-1",
provider: "github",
event: "pull_request.opened",
dedupe_key: "abc",
dedupe_source: "x-github-delivery",
signature_status: "valid",
status: "quarantined",
attempt_count: 1,
content_type: "application/json",
response_status: 200,
autopilot_run_id: null,
replayed_from_delivery_id: null,
error: null,
received_at: "2026-01-01T00:00:00Z",
last_attempt_at: "2026-01-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
},
],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res.deliveries).toHaveLength(1);
expect(res.deliveries[0]?.status).toBe("quarantined");
});
});
describe("getAutopilotDelivery", () => {
it("falls back to a placeholder carrying the requested id", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
expect(detail.id).toBe("d-1");
expect(detail.autopilot_id).toBe("ap-1");
});
});
describe("createAgentFromTemplate", () => {
it("falls back to an empty agent when the response is malformed", async () => {
// The agent was created server-side even though the client can't

View File

@@ -7,7 +7,9 @@ import type {
CreateAgentFromTemplateResponse,
GroupedIssuesResponse,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
TimelineEntry,
WebhookDelivery,
} from "../types";
// ---------------------------------------------------------------------------
@@ -196,6 +198,17 @@ export const ChildIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
}).loose();
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
workspace_id: z.string(),
agent_id: z.string(),
issue_id: z.string(),
}).loose();
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
workspace_id: z.string(),
issue_id: z.string(),
}).loose();
// ---------------------------------------------------------------------------
// Workspace dashboard schemas
//
@@ -334,6 +347,31 @@ export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateR
reused_skill_ids: [],
};
// Squad member status — backs the Squad detail page's Members tab. status
// is `string | null` (not the narrow `SquadMemberStatusValue` union) so a
// new server-side status doesn't fail the parse; the UI defaults to a
// neutral pill for unknown values.
const SquadActiveIssueBriefSchema = z.object({
issue_id: z.string(),
identifier: z.string(),
title: z.string(),
issue_status: z.string(),
}).loose();
const SquadMemberStatusSchema = z.object({
member_type: z.string(),
member_id: z.string(),
status: z.string().nullable().optional().transform((v) => v ?? null),
active_issues: z.array(SquadActiveIssueBriefSchema).default([]),
last_active_at: z.string().nullable().optional().transform((v) => v ?? null),
}).loose();
export const SquadMemberStatusListResponseSchema = z.object({
members: z.array(SquadMemberStatusSchema).default([]),
}).loose();
export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] };
// ---------------------------------------------------------------------------
// Structured error body — POST /api/workspaces/:wsId/issues 409 conflict.
//
@@ -375,3 +413,73 @@ export interface DuplicateIssueErrorBody {
title: string;
};
}
// ---------------------------------------------------------------------------
// Webhook delivery schemas — backing the Autopilot Deliveries section. Enums
// (`status`, `signature_status`, `provider`) are kept as `z.string()` so a
// future server-side value (e.g. a Stripe provider, a new dedupe state)
// degrades to a generic UI fallback rather than collapsing the list into
// the empty array. `.loose()` lets unknown fields pass through, matching
// the rule used by every other endpoint here.
// ---------------------------------------------------------------------------
const WebhookDeliverySchema = z.object({
id: z.string(),
workspace_id: z.string(),
autopilot_id: z.string(),
trigger_id: z.string(),
provider: z.string(),
event: z.string(),
dedupe_key: z.string().nullable(),
dedupe_source: z.string().nullable(),
signature_status: z.string(),
status: z.string(),
attempt_count: z.number().default(0),
content_type: z.string().nullable(),
response_status: z.number().nullable(),
autopilot_run_id: z.string().nullable(),
replayed_from_delivery_id: z.string().nullable(),
error: z.string().nullable(),
received_at: z.string(),
last_attempt_at: z.string(),
created_at: z.string(),
// Detail-only fields. The list endpoint omits them; the detail endpoint
// populates raw_body / selected_headers / response_body.
selected_headers: z.record(z.string(), z.unknown()).nullable().optional(),
raw_body: z.string().nullable().optional(),
response_body: z.string().nullable().optional(),
}).loose();
export const ListWebhookDeliveriesResponseSchema = z.object({
deliveries: z.array(WebhookDeliverySchema).default([]),
total: z.number().default(0),
}).loose();
export const WebhookDeliveryResponseSchema = WebhookDeliverySchema;
export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesResponse = {
deliveries: [],
total: 0,
};
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
id: "",
workspace_id: "",
autopilot_id: "",
trigger_id: "",
provider: "",
event: "",
dedupe_key: null,
dedupe_source: null,
signature_status: "not_required",
status: "queued",
attempt_count: 0,
content_type: null,
response_status: null,
autopilot_run_id: null,
replayed_from_delivery_id: null,
error: null,
received_at: "",
last_attempt_at: "",
created_at: "",
};

View File

@@ -1,4 +1,11 @@
export { autopilotKeys, autopilotListOptions, autopilotDetailOptions, autopilotRunsOptions } from "./queries";
export {
autopilotKeys,
autopilotListOptions,
autopilotDetailOptions,
autopilotRunsOptions,
autopilotDeliveriesOptions,
autopilotDeliveryOptions,
} from "./queries";
export {
useCreateAutopilot,
useUpdateAutopilot,
@@ -8,5 +15,6 @@ export {
useUpdateAutopilotTrigger,
useDeleteAutopilotTrigger,
useRotateAutopilotTriggerWebhookToken,
useReplayAutopilotDelivery,
} from "./mutations";
export { buildAutopilotWebhookUrl } from "./webhook";

View File

@@ -140,3 +140,20 @@ export function useRotateAutopilotTriggerWebhookToken() {
},
});
}
// Replay re-dispatches a previously-recorded delivery. The server creates
// a new delivery row (with `replayed_from_delivery_id`) and synchronously
// kicks off a new autopilot run. We invalidate both deliveries and runs so
// the new delivery and any resulting run show up immediately.
export function useReplayAutopilotDelivery() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, deliveryId }: { autopilotId: string; deliveryId: string }) =>
api.replayAutopilotDelivery(autopilotId, deliveryId),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.deliveries(wsId, vars.autopilotId) });
qc.invalidateQueries({ queryKey: autopilotKeys.runs(wsId, vars.autopilotId) });
},
});
}

View File

@@ -10,6 +10,10 @@ export const autopilotKeys = {
[...autopilotKeys.all(wsId), "runs", id] as const,
run: (wsId: string, autopilotId: string, runId: string) =>
[...autopilotKeys.all(wsId), "runs", autopilotId, runId] as const,
deliveries: (wsId: string, id: string) =>
[...autopilotKeys.all(wsId), "deliveries", id] as const,
delivery: (wsId: string, autopilotId: string, deliveryId: string) =>
[...autopilotKeys.all(wsId), "deliveries", autopilotId, deliveryId] as const,
};
export function autopilotListOptions(wsId: string) {
@@ -51,3 +55,35 @@ export function autopilotRunOptions(
enabled: options?.enabled ?? true,
});
}
// autopilotDeliveriesOptions powers the Deliveries section in the autopilot
// detail page. The list is slim — raw_body / selected_headers / response_body
// are omitted server-side. Detail rows are fetched on-demand when the user
// expands a row (see autopilotDeliveryOptions).
export function autopilotDeliveriesOptions(
wsId: string,
autopilotId: string,
options?: { enabled?: boolean },
) {
return queryOptions({
queryKey: autopilotKeys.deliveries(wsId, autopilotId),
queryFn: () => api.listAutopilotDeliveries(autopilotId),
select: (data) => data.deliveries,
enabled: options?.enabled ?? true,
});
}
// autopilotDeliveryOptions fetches the full delivery row including raw_body
// and headers subset. Used by the detail dialog opened from a list row.
export function autopilotDeliveryOptions(
wsId: string,
autopilotId: string,
deliveryId: string,
options?: { enabled?: boolean },
) {
return queryOptions({
queryKey: autopilotKeys.delivery(wsId, autopilotId, deliveryId),
queryFn: () => api.getAutopilotDelivery(autopilotId, deliveryId),
enabled: options?.enabled ?? true,
});
}

View File

@@ -1,74 +1,45 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
// Workspace dashboard query options. All four endpoints share the same
// (wsId, days, projectId, squadId) key shape so workspace switching,
// time-range changes, the project filter, and the squad filter each
// invalidate the cache cleanly.
// Workspace dashboard query options. All three endpoints share the same
// (wsId, days, projectId) key shape so workspace switching, time-range
// changes, and the project filter each invalidate the cache cleanly.
//
// `projectId` and `squadId` are normalised to `null` (not undefined /
// "all") so the queryKey shape is stable across renders when either
// dropdown sits on its "all" sentinel.
// The cache key includes `wsId` explicitly: TanStack Query already isolates
// per workspace via the key, but threading wsId into the queryFn lets
// callers fail fast (return [] on empty wsId) instead of issuing a request
// the server would reject.
//
// `projectId` is normalised to `null` (not undefined / "all") so the
// queryKey shape is stable across renders even when the dropdown sits on
// "all projects".
export const dashboardKeys = {
all: (wsId: string) => ["dashboard", wsId] as const,
daily: (
wsId: string,
days: number,
projectId: string | null,
squadId: string | null,
) => [...dashboardKeys.all(wsId), "daily", days, projectId, squadId] as const,
byAgent: (
wsId: string,
days: number,
projectId: string | null,
squadId: string | null,
) =>
[...dashboardKeys.all(wsId), "by-agent", days, projectId, squadId] as const,
agentRuntime: (
wsId: string,
days: number,
projectId: string | null,
squadId: string | null,
) =>
[
...dashboardKeys.all(wsId),
"agent-runtime",
days,
projectId,
squadId,
] as const,
runTimeDaily: (
wsId: string,
days: number,
projectId: string | null,
squadId: string | null,
) =>
[
...dashboardKeys.all(wsId),
"runtime-daily",
days,
projectId,
squadId,
] as const,
daily: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
byAgent: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
runTimeDaily: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "runtime-daily", days, projectId] as const,
};
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
// driven on the server (5-min rollup cadence) and the dashboard isn't a
// real-time view, so background refetches every minute are plenty.
const STALE_TIME = 60 * 1000;
export function dashboardUsageDailyOptions(
wsId: string,
days: number,
projectId: string | null,
squadId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.daily(wsId, days, projectId, squadId),
queryKey: dashboardKeys.daily(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageDaily({
days,
project_id: projectId ?? undefined,
squad_id: squadId ?? undefined,
}),
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
@@ -78,16 +49,11 @@ export function dashboardUsageByAgentOptions(
wsId: string,
days: number,
projectId: string | null,
squadId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.byAgent(wsId, days, projectId, squadId),
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageByAgent({
days,
project_id: projectId ?? undefined,
squad_id: squadId ?? undefined,
}),
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
@@ -97,16 +63,11 @@ export function dashboardAgentRunTimeOptions(
wsId: string,
days: number,
projectId: string | null,
squadId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId, squadId),
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
queryFn: () =>
api.getDashboardAgentRunTime({
days,
project_id: projectId ?? undefined,
squad_id: squadId ?? undefined,
}),
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
@@ -116,16 +77,11 @@ export function dashboardRunTimeDailyOptions(
wsId: string,
days: number,
projectId: string | null,
squadId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId, squadId),
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
queryFn: () =>
api.getDashboardRunTimeDaily({
days,
project_id: projectId ?? undefined,
squad_id: squadId ?? undefined,
}),
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});

View File

@@ -1,3 +1,4 @@
export * from "./queries";
export * from "./mutations";
export * from "./ws-updaters";
export * from "./stores";

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { InboxItem } from "../types";
import type { InboxItem, InboxFilterScope } from "../types";
export function useMarkInboxRead() {
const qc = useQueryClient();
@@ -22,6 +22,7 @@ export function useMarkInboxRead() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -51,21 +52,27 @@ export function useArchiveInbox() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
// All bulk mutations accept an optional `scope` parameter. When the caller
// is in mode=all (RFC v3 §E.1) it should pass undefined; when in mode=subset
// it should pass the resolved chip subset; in mode=empty the button is
// disabled and these mutations should not fire.
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
mutationFn: (scope?: InboxFilterScope[]) => api.markAllInboxRead(scope),
onMutate: async (scope) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
const inScope = scopeMatcher(scope);
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
!item.archived && inScope(item) ? { ...item, read: true } : item,
),
);
return { prev };
@@ -75,6 +82,7 @@ export function useMarkAllInboxRead() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -83,9 +91,10 @@ export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -94,9 +103,10 @@ export function useArchiveAllReadInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllReadInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -105,9 +115,21 @@ export function useArchiveCompletedInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveCompletedInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
// True when the inbox item belongs to the user-selected scope subset, or
// when no scope was passed (= mark/archive everything).
function scopeMatcher(scope?: InboxFilterScope[]) {
if (!scope || scope.length === 0) return (_item: InboxItem) => true;
const set = new Set(scope);
return (item: InboxItem) => {
const s = item.assignee_scope;
return s != null && (set as Set<string>).has(s);
};
}

View File

@@ -1,19 +1,49 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem } from "../types";
import type {
InboxItem,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
} from "../types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
// The list key is intentionally a single key per workspace — the scope
// filter is applied client-side on top of the full cached list (RFC v3
// §E selector), so we don't fragment the cache by scope. When the user
// changes chips we just re-derive from the same query.
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
scopeCounts: (wsId: string) =>
[...inboxKeys.all(wsId), "scope-counts"] as const,
resourceAvailability: (wsId: string) =>
[...inboxKeys.all(wsId), "resource-availability"] as const,
};
export function inboxListOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.list(wsId),
// Always fetch the full list (no scope param). The chip filter runs in
// the selector — that way the badge counts and the dedupe logic always
// operate on the complete picture, and toggling a chip is instant.
queryFn: () => api.listInbox(),
});
}
export function inboxScopeCountsOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.scopeCounts(wsId),
queryFn: () => api.getInboxScopeCounts(),
});
}
export function inboxResourceAvailabilityOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.resourceAvailability(wsId),
queryFn: () => api.getInboxResourceAvailability(),
});
}
/**
* Unread inbox count for the given workspace, aligned with what the inbox
* list UI renders: archived items excluded, then deduplicated by issue so a
@@ -57,3 +87,29 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}
/**
* Narrow a deduplicated inbox list to the user-selected chips. Applies the
* RFC v3 §E selector rules: a strict subset of {me, my_agent, my_squad}
* keeps only items tagged with one of those scopes (other/none are dropped);
* a null filter (= "all" mode) passes everything through unchanged.
*
* `null` is the no-op signal. Pass `null` whenever you don't want to filter,
* including the empty-mode case where the caller is also expected to render
* an empty state instead of calling this.
*/
export function filterInboxByScope(
items: InboxItem[],
scopes: InboxFilterScope[] | null,
): InboxItem[] {
if (!scopes) return items;
const set = new Set(scopes);
return items.filter((i) => {
const s = i.assignee_scope;
return s != null && (set as Set<string>).has(s);
});
}
// Re-exports — kept for backwards compatibility with code importing the
// inbox scope-count / availability response shapes from this module.
export type { InboxScopeCounts, InboxResourceAvailability };

View File

@@ -0,0 +1,83 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
import type { InboxFilterScope } from "../../types";
// All three assignment chips, in stable display order. Used both for the
// "default = all selected" initial state and for callers that need to render
// chips deterministically.
export const INBOX_FILTER_SCOPES: readonly InboxFilterScope[] = [
"me",
"my_agent",
"my_squad",
] as const;
interface InboxScopeState {
// Persisted selection. The default is the full set so a freshly installed
// app shows every notification — see RFC v3 §E.1 mode=all.
selected: InboxFilterScope[];
toggle: (scope: InboxFilterScope) => void;
set: (scopes: InboxFilterScope[]) => void;
selectAll: () => void;
clear: () => void;
}
export const useInboxScopeStore = create<InboxScopeState>()(
persist(
(set) => ({
selected: [...INBOX_FILTER_SCOPES],
toggle: (scope) =>
set((state) => ({
selected: state.selected.includes(scope)
? state.selected.filter((s) => s !== scope)
: [...state.selected, scope],
})),
set: (scopes) => set({ selected: scopes }),
selectAll: () => set({ selected: [...INBOX_FILTER_SCOPES] }),
clear: () => set({ selected: [] }),
}),
{
name: "multica_inbox_scope",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useInboxScopeStore.persist.rehydrate());
// Resolved filter mode. Matches the three-state algorithm in RFC v3 §E.1:
// - all: 3 selected → no `scope` is sent; selector keeps me/my_agent/my_squad/other/none
// - subset: 1-2 selected → `scope=...` is sent; selector filters to the subset
// - empty: 0 selected → don't request; show empty state, bulk disabled
export type InboxFilterMode = "all" | "subset" | "empty";
export interface InboxFilterResolution {
mode: InboxFilterMode;
// Scopes to send on the wire. `null` for mode="all" (omit param entirely),
// a string[] for mode="subset", `[]` for mode="empty".
scopes: InboxFilterScope[] | null;
}
export function resolveInboxFilter(
selected: InboxFilterScope[],
): InboxFilterResolution {
// Dedupe + restrict to the three valid chip values. "other" / "none" are
// server-internal buckets and must never appear on the wire.
const unique = new Set<InboxFilterScope>();
for (const s of selected) {
if (s === "me" || s === "my_agent" || s === "my_squad") unique.add(s);
}
if (unique.size === INBOX_FILTER_SCOPES.length) {
return { mode: "all", scopes: null };
}
if (unique.size === 0) {
return { mode: "empty", scopes: [] };
}
return {
mode: "subset",
scopes: INBOX_FILTER_SCOPES.filter((s) => unique.has(s)),
};
}

View File

@@ -0,0 +1,7 @@
export {
useInboxScopeStore,
resolveInboxFilter,
INBOX_FILTER_SCOPES,
type InboxFilterMode,
type InboxFilterResolution,
} from "./inbox-scope-store";

View File

@@ -10,6 +10,19 @@ export function onInboxNew(
// Use invalidateQueries instead of setQueryData — triggers a refetch that
// reliably notifies all observers. The inbox list is small so this is cheap.
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// `inbox:batch-read` and `inbox:batch-archived` are emitted when the user
// runs a bulk endpoint (mark-all-read / archive-*). They can carry a `scope`
// filter (RFC v3 §C.5) and `inbox:batch-archived` additionally carries an
// `operation` (RFC v4 §1). We currently fall back to a generic invalidate
// for both — precise cache updates per operation+scope are a documented
// follow-up: the payload contract is already in place, so the optimization
// is a frontend-only change later.
export function onInboxBatch(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
export function onInboxIssueStatusChanged(
@@ -27,7 +40,9 @@ export function onInboxIssueStatusChanged(
// Mirrors the DB-level ON DELETE CASCADE on inbox_item.issue_id: when an issue
// is deleted, all inbox items that referenced it are gone server-side, so drop
// them from the cache too.
// them from the cache too. Scope counts shift in lockstep with the pruned
// rows, so invalidate them here as well — otherwise the chip badge keeps
// counting an issue that no longer exists.
export function onInboxIssueDeleted(
qc: QueryClient,
wsId: string,
@@ -36,8 +51,14 @@ export function onInboxIssueDeleted(
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.filter((i) => i.issue_id !== issueId),
);
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// Generic single-item inbox invalidation (e.g. `inbox:archived`,
// `inbox:read`). The chip badge is derived from the same rows that just
// changed, so it has to be re-fetched alongside the list — otherwise the
// badge stays at the pre-change value until a hard refresh.
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}

View File

@@ -4,6 +4,7 @@ import { api } from "../api";
import {
issueKeys,
ISSUE_PAGE_SIZE,
PAGINATED_STATUSES,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "./queries";
@@ -24,6 +25,7 @@ import {
pruneDeletedIssueFromParentChildrenCaches,
} from "./delete-cache";
import { useWorkspaceId } from "../hooks";
import { inboxKeys } from "../inbox/queries";
import { useRecentIssuesStore } from "./stores";
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
import type {
@@ -103,6 +105,75 @@ export function useLoadMoreByStatus(
return { loadMore, hasMore, isLoading, total };
}
/**
* Drain every remaining paginated page across all statuses into the cache.
* Used by surfaces that can't paginate per-column (e.g. the Project Gantt
* view) and need the full project issue set up-front. Each iteration appends
* one ISSUE_PAGE_SIZE page per status that still has unfetched rows; loops
* until the cache totals match the server.
*/
export function useLoadAllRemaining(
myIssues?: { scope: string; filter: MyIssuesFilter },
) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const queryKey = myIssues
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
: issueKeys.list(wsId);
const loadAll = useCallback(async () => {
if (isLoading) return;
setIsLoading(true);
try {
// Round-trip the cache rather than caching `loaded` locally so a
// concurrent WS-driven update or another loadMore can't make us
// re-fetch an already-loaded page.
for (;;) {
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
if (!cache) return;
const pending = PAGINATED_STATUSES.filter((status) => {
const bucket = cache.byStatus[status];
if (!bucket) return false;
return bucket.issues.length < bucket.total;
});
if (pending.length === 0) return;
const results = await Promise.all(
pending.map((status) =>
api
.listIssues({
status,
limit: ISSUE_PAGE_SIZE,
offset: cache.byStatus[status]!.issues.length,
...myIssues?.filter,
})
.then((res) => ({ status, res })),
),
);
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
if (!old) return old;
let next = old;
for (const { status, res } of results) {
const prev = getBucket(next, status);
const existingIds = new Set(prev.issues.map((i) => i.id));
const appended = res.issues.filter((i) => !existingIds.has(i.id));
next = setBucket(next, status, {
issues: [...prev.issues, ...appended],
total: res.total,
});
}
return next;
});
}
} finally {
setIsLoading(false);
}
}, [isLoading, qc, queryKey, myIssues?.filter]);
return { loadAll, isLoading };
}
export function useLoadMoreByAssigneeGroup(
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
queryKey: QueryKey,
@@ -257,6 +328,20 @@ export function useUpdateIssue() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Inbox rows carry a server-computed `assignee_scope` derived from
// the issue's assignee. Re-assigning the issue (member ↔ agent ↔
// squad ↔ none) shifts the row's chip bucket and the scope-count
// badge, so flush both whenever this mutation touched assignment.
// The WS handler also invalidates on the broadcast issue:updated;
// doing it here too lets the originating tab refresh without
// round-tripping through the server.
if (
Object.prototype.hasOwnProperty.call(vars, "assignee_id") ||
Object.prototype.hasOwnProperty.call(vars, "assignee_type")
) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
@@ -395,10 +480,19 @@ export function useBatchUpdateIssues() {
}
}
},
onSettled: (_data, _err, _vars, ctx) => {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Bulk reassignments shift `assignee_scope` across N rows — same
// reasoning as useUpdateIssue.
if (
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_id") ||
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_type")
) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({

View File

@@ -55,7 +55,7 @@ export const issueKeys = {
export type MyIssuesFilter = Pick<
ListIssuesParams,
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
"assignee_id" | "assignee_ids" | "creator_id" | "project_id" | "involves_user_id"
>;
export type AssigneeGroupedIssuesFilter = Omit<
@@ -79,6 +79,34 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
return out;
}
export interface IssueListPagination {
loaded: number;
total: number;
hasMore: boolean;
}
/**
* Aggregate the bucketed cache totals so non-paginated consumers (e.g. the
* Gantt view, which doesn't have a per-status load-more affordance) can tell
* whether the cache is missing pages and warn the user instead of silently
* rendering an incomplete schedule.
*/
export function summarizeIssueListPagination(
data: ListIssuesCache | undefined,
): IssueListPagination {
if (!data) return { loaded: 0, total: 0, hasMore: false };
let loaded = 0;
let total = 0;
for (const status of PAGINATED_STATUSES) {
const bucket = data.byStatus[status];
if (bucket) {
loaded += bucket.issues.length;
total += bucket.total;
}
}
return { loaded, total, hasMore: loaded < total };
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
@@ -142,6 +170,24 @@ export function myIssueListOptions(
});
}
/**
* Same cache entry as {@link myIssueListOptions} (shared queryKey + queryFn —
* TanStack Query dedupes), but `select` derives a pagination summary instead
* of the flat issue list. Use this alongside the list query when a consumer
* needs to know how many issues live behind unfetched pages.
*/
export function myIssueListPaginationOptions(
wsId: string,
scope: string,
filter: MyIssuesFilter,
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: () => fetchFirstPages(filter),
select: summarizeIssueListPagination,
});
}
export function myIssueAssigneeGroupsOptions(
wsId: string,
scope: string,

View File

@@ -23,6 +23,8 @@ const _actorIssuesViewStore = createStore<ActorIssuesViewState>()(
persist(
(set) => ({
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
// Actor tasks panel is list-only; override the slice's "board" default.
viewMode: "list",
scope: "assigned" as ActorIssuesScope,
setScope: (scope: ActorIssuesScope) => set({ scope }),
}),

View File

@@ -9,7 +9,8 @@ import { ALL_STATUSES } from "../config";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
export type ViewMode = "board" | "list" | "gantt";
export type GanttZoom = "day" | "week" | "month";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
@@ -70,7 +71,11 @@ export interface IssueViewState {
sortDirection: SortDirection;
cardProperties: CardProperties;
listCollapsedStatuses: IssueStatus[];
ganttZoom: GanttZoom;
ganttShowCompleted: boolean;
setViewMode: (mode: ViewMode) => void;
setGanttZoom: (zoom: GanttZoom) => void;
toggleGanttShowCompleted: () => void;
setGrouping: (grouping: IssueGrouping) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
@@ -113,8 +118,13 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
labels: true,
},
listCollapsedStatuses: [],
ganttZoom: "week",
ganttShowCompleted: false,
setViewMode: (mode) => set({ viewMode: mode }),
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
toggleGanttShowCompleted: () =>
set((state) => ({ ganttShowCompleted: !state.ganttShowCompleted })),
setGrouping: (grouping) => set({ grouping }),
toggleStatusFilter: (status) =>
set((state) => ({
@@ -232,6 +242,8 @@ export const viewStorePersistOptions = (name: string) => ({
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
ganttZoom: state.ganttZoom,
ganttShowCompleted: state.ganttShowCompleted,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
// saved before a new toggle was introduced wins entirely and the new key is

View File

@@ -2,13 +2,15 @@ export type {
OnboardingStep,
OnboardingCompletionPath,
QuestionnaireAnswers,
TeamSize,
Source,
Role,
UseCase,
} from "./types";
export {
saveQuestionnaire,
completeOnboarding,
bootstrapRuntimeOnboarding,
bootstrapNoRuntimeOnboarding,
joinCloudWaitlist,
} from "./store";
export { ONBOARDING_STEP_ORDER } from "./step-order";

View File

@@ -3,113 +3,145 @@ import { recommendTemplate } from "./recommend-template";
import type { Role, UseCase } from "./types";
const ALL_USE_CASES: UseCase[] = [
"coding",
"planning",
"writing_research",
"explore",
"ship_code",
"manage_team",
"personal_tasks",
"plan_research",
"write_publish",
"automate_ops",
"evaluate",
"other",
];
describe("recommendTemplate", () => {
describe("identity fallbacks — role alone decides", () => {
it.each(ALL_USE_CASES)(
"role=other (use_case=%s) → assistant",
const ALL_ROLES: Role[] = [
"engineer",
"product",
"designer",
"founder",
"marketing",
"writer",
"research",
"ops",
"student",
"other",
];
describe("recommendTemplate (v2)", () => {
describe("engineer × use_case tiebreaker", () => {
it.each<UseCase>(["manage_team", "plan_research"])(
"engineer × %s → planning",
(use_case) => {
expect(recommendTemplate({ role: "other", use_case })).toBe(
"assistant",
expect(recommendTemplate({ role: "engineer", use_case })).toBe(
"planning",
);
},
);
it("engineer × write_publish → writing", () => {
expect(
recommendTemplate({ role: "engineer", use_case: "write_publish" }),
).toBe("writing");
});
it.each<UseCase>([
"ship_code",
"personal_tasks",
"automate_ops",
"evaluate",
"other",
])("engineer × %s → coding", (use_case) => {
expect(recommendTemplate({ role: "engineer", use_case })).toBe("coding");
});
it("engineer × null → coding", () => {
expect(recommendTemplate({ role: "engineer", use_case: null })).toBe(
"coding",
);
});
});
it.each(ALL_USE_CASES)(
"role=founder (use_case=%s) → assistant",
describe("product × use_case", () => {
it("product × ship_code → coding", () => {
expect(
recommendTemplate({ role: "product", use_case: "ship_code" }),
).toBe("coding");
});
it.each<UseCase>(["manage_team", "plan_research", "evaluate", "other"])(
"product × %s → planning",
(use_case) => {
expect(recommendTemplate({ role: "founder", use_case })).toBe(
"assistant",
expect(recommendTemplate({ role: "product", use_case })).toBe(
"planning",
);
},
);
it("product × null → planning", () => {
expect(recommendTemplate({ role: "product", use_case: null })).toBe(
"planning",
);
});
});
it.each(ALL_USE_CASES)(
"role=writer (use_case=%s) → writing",
describe("marketing × use_case", () => {
it.each<UseCase>(["write_publish", "plan_research"])(
"marketing × %s → writing",
(use_case) => {
expect(recommendTemplate({ role: "writer", use_case })).toBe(
expect(recommendTemplate({ role: "marketing", use_case })).toBe(
"writing",
);
},
);
});
describe("developer × use_case tiebreaker", () => {
it("developer × planning → planning", () => {
it("marketing × manage_team → planning", () => {
expect(
recommendTemplate({ role: "developer", use_case: "planning" }),
recommendTemplate({ role: "marketing", use_case: "manage_team" }),
).toBe("planning");
});
it.each<UseCase>([
"coding",
"writing_research",
"explore",
"other",
])("developer × %s → coding", (use_case) => {
expect(recommendTemplate({ role: "developer", use_case })).toBe(
"coding",
);
});
it("developer × null use_case → coding (default)", () => {
expect(
recommendTemplate({ role: "developer", use_case: null }),
).toBe("coding");
});
});
describe("product_lead × use_case tiebreaker", () => {
it("product_lead × coding → coding", () => {
expect(
recommendTemplate({ role: "product_lead", use_case: "coding" }),
).toBe("coding");
describe("single-template roles", () => {
it.each(ALL_USE_CASES)("writer × %s → writing", (use_case) => {
expect(recommendTemplate({ role: "writer", use_case })).toBe("writing");
});
it.each<UseCase>([
"planning",
"writing_research",
"explore",
"other",
])("product_lead × %s → planning", (use_case) => {
expect(recommendTemplate({ role: "product_lead", use_case })).toBe(
it.each(ALL_USE_CASES)("designer × %s → assistant", (use_case) => {
expect(recommendTemplate({ role: "designer", use_case })).toBe(
"assistant",
);
});
it.each(ALL_USE_CASES)("research × %s → planning", (use_case) => {
expect(recommendTemplate({ role: "research", use_case })).toBe(
"planning",
);
});
it("product_lead × null use_case → planning (default)", () => {
expect(
recommendTemplate({ role: "product_lead", use_case: null }),
).toBe("planning");
});
it.each<Role>(["founder", "ops", "student", "other"])(
"%s → assistant",
(role) => {
expect(recommendTemplate({ role, use_case: null })).toBe("assistant");
},
);
});
describe("unanswered questionnaire", () => {
it("null role → assistant regardless of use_case", () => {
expect(recommendTemplate({ role: null, use_case: null })).toBe(
"assistant",
describe("role skipped — use_case fallback", () => {
it("null × ship_code → coding", () => {
expect(recommendTemplate({ role: null, use_case: "ship_code" })).toBe(
"coding",
);
expect(recommendTemplate({ role: null, use_case: "coding" })).toBe(
});
it("null × write_publish → writing", () => {
expect(
recommendTemplate({ role: null, use_case: "write_publish" }),
).toBe("writing");
});
it.each<UseCase>(["manage_team", "plan_research"])(
"null × %s → planning",
(use_case) => {
expect(recommendTemplate({ role: null, use_case })).toBe("planning");
},
);
it("both null → assistant", () => {
expect(recommendTemplate({ role: null, use_case: null })).toBe(
"assistant",
);
});
});
describe("exhaustive role coverage", () => {
const roles: Role[] = [
"developer",
"product_lead",
"writer",
"founder",
"other",
];
it.each(roles)("role=%s returns a valid template id", (role) => {
it.each(ALL_ROLES)("role=%s returns a valid template id", (role) => {
const result = recommendTemplate({ role, use_case: null });
expect(["coding", "planning", "writing", "assistant"]).toContain(result);
});

View File

@@ -1,41 +1,69 @@
import type { QuestionnaireAnswers } from "./types";
import type { QuestionnaireAnswers, Role, UseCase } from "./types";
/**
* Identifier for the four agent templates offered during onboarding Step 4.
* Keep in sync with the template registry inside StepAgent in
* Identifier for the four legacy onboarding agent templates. Keep in
* sync with the template registry inside StepAgent in
* `packages/views/onboarding/steps/step-agent.tsx`.
*/
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
/**
* Pick a recommended agent template for a user based on their
* questionnaire answers. Role is treated as the primary signal (who the
* user is); use_case is only a tiebreaker for roles that legitimately
* split between templates (developer / product_lead).
* Pick a recommended agent template based on the v2 questionnaire
* (role × use_case). Role is the primary signal; use_case is a
* tiebreaker for roles that legitimately split between templates
* (engineer / product / marketing).
*
* `role = other` and `role = founder` both fall back to the generic
* Assistant: "other" means the user declined to claim a role, and
* "founder" means they wear every hat, so a single specialized agent is
* a poor default.
* Fallback chain when role is skipped or null:
* 1. Derive from use_case alone.
* 2. Both unknown → `assistant` (the generic default).
*
* Pure / deterministic — safe to call on every render.
*/
export function recommendTemplate(
answers: Pick<QuestionnaireAnswers, "role" | "use_case">,
): AgentTemplateId {
const { role, use_case } = answers;
const role: Role | null = answers.role;
const useCase: UseCase | null = answers.use_case;
if (role === "other" || role === "founder") return "assistant";
if (role === "writer") return "writing";
if (role === null) return fallbackFromUseCase(useCase);
if (role === "developer") {
return use_case === "planning" ? "planning" : "coding";
switch (role) {
case "engineer":
if (useCase === "manage_team" || useCase === "plan_research")
return "planning";
if (useCase === "write_publish") return "writing";
return "coding";
case "product":
if (useCase === "ship_code") return "coding";
return "planning";
case "designer":
return "assistant";
case "writer":
return "writing";
case "marketing":
if (useCase === "write_publish" || useCase === "plan_research")
return "writing";
return "planning";
case "research":
return "planning";
case "founder":
case "ops":
case "student":
case "other":
return "assistant";
}
}
function fallbackFromUseCase(useCase: UseCase | null): AgentTemplateId {
switch (useCase) {
case "ship_code":
return "coding";
case "write_publish":
return "writing";
case "manage_team":
case "plan_research":
return "planning";
default:
return "assistant";
}
if (role === "product_lead") {
return use_case === "coding" ? "coding" : "planning";
}
// Unknown / null role — user hasn't answered Q2 yet.
return "assistant";
}

View File

@@ -15,9 +15,10 @@ import type { OnboardingStep } from "./types";
* as progress toward completing setup.
*/
export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
"questionnaire",
"source",
"role",
"use_case",
"workspace",
"runtime",
"agent",
"first_issue",
"teammate",
] as const;

View File

@@ -4,14 +4,16 @@ import { setPersonProperties } from "../analytics";
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
/**
* Persist Q1/Q2/Q3 answers and sync the refreshed user into the auth
* store. Source of truth is `user.onboarding_questionnaire` (JSONB on
* the server). No client-side cache here.
* Persist questionnaire answers (one or more slots at a time — each
* onboarding step PATCHes its own slot) and sync the refreshed user
* into the auth store. Source of truth is
* `user.onboarding_questionnaire` (JSONB on the server). No
* client-side cache here.
*
* Resume-by-step is intentionally not persisted: every onboarding
* entry starts at Welcome. The questionnaire is the only piece of
* progress that survives a re-entry — it pre-fills Step 1 so the
* user doesn't re-answer.
* entry starts at Welcome. Answered slots are pre-filled on
* re-entry; skipped slots are treated as fresh (the user can answer
* this time).
*/
export async function saveQuestionnaire(
answers: Partial<QuestionnaireAnswers>,
@@ -19,12 +21,11 @@ export async function saveQuestionnaire(
const user = await api.patchOnboarding({ questionnaire: answers });
useAuthStore.getState().setUser(user);
// Mirror the three cohort signals into person properties so every
// PostHog event on this user can be broken down by role / use_case /
// team_size without re-joining the DB. Matches the $set block the
// server writes alongside `onboarding_questionnaire_submitted`.
if (answers.team_size || answers.role || answers.use_case) {
// PostHog event on this user can be broken down by source / role /
// use_case without re-joining the DB.
if (answers.source || answers.role || answers.use_case) {
setPersonProperties({
...(answers.team_size ? { team_size: answers.team_size } : {}),
...(answers.source ? { source: answers.source } : {}),
...(answers.role ? { role: answers.role } : {}),
...(answers.use_case ? { use_case: answers.use_case } : {}),
});
@@ -52,6 +53,38 @@ export async function completeOnboarding(
await useAuthStore.getState().refreshMe();
}
/**
* Runtime-connected onboarding path. The server creates or reuses the
* default Multica Helper agent and the single onboarding issue, marks
* onboarding complete, and suppresses the older starter-content prompt.
*/
export async function bootstrapRuntimeOnboarding(
workspaceId: string,
runtimeId: string,
): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> {
const result = await api.bootstrapOnboardingRuntime({
workspace_id: workspaceId,
runtime_id: runtimeId,
});
await useAuthStore.getState().refreshMe();
return result;
}
/**
* Runtime-skipped onboarding path. The server creates or reuses one
* self-serve onboarding issue, marks onboarding complete, and suppresses
* the older starter-content prompt so the user is not flooded with tasks.
*/
export async function bootstrapNoRuntimeOnboarding(
workspaceId: string,
): Promise<{ workspace_id: string; issue_id: string }> {
const result = await api.bootstrapOnboardingNoRuntime({
workspace_id: workspaceId,
});
await useAuthStore.getState().refreshMe();
return result;
}
/**
* Records interest in cloud runtimes. Pure side effect — does NOT
* complete onboarding; the user still has to pick a real Step 3

View File

@@ -1,8 +1,11 @@
export type OnboardingStep =
| "welcome"
| "questionnaire"
| "source"
| "role"
| "use_case"
| "workspace"
| "runtime"
| "teammate"
| "agent"
| "first_issue";
@@ -13,33 +16,64 @@ export type OnboardingStep =
* `OnboardingPath*` constants in `server/internal/analytics/events.go`.
*/
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
| "invite_accept"; // Accepted at least one invite from /invitations
| "full"
| "runtime_skipped"
| "cloud_waitlist"
| "skip_existing"
| "invite_accept";
export type TeamSize = "solo" | "team" | "other";
export type Source =
| "friends_colleagues"
| "search"
| "social_x"
| "social_linkedin"
| "social_youtube"
| "social_other"
| "blog_newsletter"
| "ai_assistant"
| "from_work"
| "event_conference"
| "dont_remember"
| "other";
export type Role =
| "developer"
| "product_lead"
| "writer"
| "engineer"
| "product"
| "designer"
| "founder"
| "marketing"
| "writer"
| "research"
| "ops"
| "student"
| "other";
export type UseCase =
| "coding"
| "planning"
| "writing_research"
| "explore"
| "ship_code"
| "manage_team"
| "personal_tasks"
| "plan_research"
| "write_publish"
| "automate_ops"
| "evaluate"
| "other";
/**
* v2 questionnaire shape. `*_skipped: true` distinguishes an explicit
* Skip click from a slot the user never reached. Both states are
* "unknown" for recommendation purposes; the skip marker exists for
* analytics and so future re-prompts can avoid nagging users who
* already declined.
*/
export interface QuestionnaireAnswers {
team_size: TeamSize | null;
team_size_other: string | null;
source: Source | null;
source_other: string | null;
source_skipped: boolean;
role: Role | null;
role_other: string | null;
role_skipped: boolean;
use_case: UseCase | null;
use_case_other: string | null;
use_case_skipped: boolean;
version: 2;
}

View File

@@ -37,6 +37,8 @@
"./inbox/queries": "./inbox/queries.ts",
"./inbox/mutations": "./inbox/mutations.ts",
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
"./inbox/stores": "./inbox/stores/index.ts",
"./inbox/stores/*": "./inbox/stores/*.ts",
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",

View File

@@ -22,6 +22,7 @@ describe("paths.workspace(slug)", () => {
expect(ws.squads()).toBe("/acme/squads");
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
expect(ws.settings()).toBe("/acme/settings");
expect(ws.attachmentPreview("att_42")).toBe("/acme/attachments/att_42/preview");
});
it("URL-encodes special characters in ids", () => {

View File

@@ -37,6 +37,7 @@ function workspaceScoped(slug: string) {
skills: () => `${ws}/skills`,
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
settings: () => `${ws}/settings`,
attachmentPreview: (id: string) => `${ws}/attachments/${encode(id)}/preview`,
};
}

View File

@@ -102,8 +102,8 @@ describe("useRealtimeSync — ws instance change", () => {
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (11 workspace-scoped + 1 workspaceKeys.list() = 12 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(12);
// (12 workspace-scoped + 1 workspaceKeys.list() = 13 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(13);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {

View File

@@ -1,8 +1,18 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { chatKeys } from "../chat/queries";
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
import { applyChatDoneToCache } from "./use-realtime-sync";
import { issueKeys } from "../issues/queries";
import { workspaceKeys } from "../workspace/queries";
import type {
ChatDonePayload,
ChatMessage,
ChatPendingTask,
Workspace,
} from "../types";
import {
applyChatDoneToCache,
applyWorkspaceUpdatedToCache,
} from "./use-realtime-sync";
const sessionId = "session-1";
const taskId = "task-1";
@@ -115,3 +125,78 @@ describe("applyChatDoneToCache", () => {
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
});
describe("applyWorkspaceUpdatedToCache", () => {
const wsId = "ws-1";
function workspace(overrides: Partial<Workspace> = {}): Workspace {
return {
id: wsId,
name: "Test",
slug: "test",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "TES",
created_at: "2026-05-18T00:00:00Z",
updated_at: "2026-05-18T00:00:00Z",
...overrides,
};
}
it("invalidates issue cache when issue_prefix changes", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("does not invalidate issue cache when only non-prefix fields change", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES", name: "Old name" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "TES", name: "New name" }),
});
expect(invalidate).not.toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("invalidates issue cache when the workspace isn't in the cached list yet", () => {
// Conservative: a workspace appearing for the first time may correspond
// to issue queries that were primed without ever seeing the (possibly
// changing) prefix. Erring on the side of refresh keeps identifiers
// accurate at minimal cost.
const qc = createQueryClient();
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
});
});

View File

@@ -27,16 +27,18 @@ import {
onIssueDeleted,
onIssueLabelsChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxBatch } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { notificationPreferenceOptions } from "../notification-preferences/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import type { Workspace } from "../types/workspace";
import { chatKeys } from "../chat/queries";
import { useChatStore } from "../chat";
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
WorkspaceUpdatedPayload,
MemberRemovedPayload,
IssueUpdatedPayload,
IssueCreatedPayload,
@@ -107,6 +109,36 @@ export function applyChatDoneToCache(
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
}
/**
* Apply a workspace:updated event to the cache. Always refreshes the
* workspace list. If the incoming `issue_prefix` differs from what's
* currently cached, also invalidates issueKeys.all for that workspace,
* since every issue's rendered identifier (`MUL-123`) is recomputed from
* the workspace prefix at read time. Without this, the UI keeps showing
* the old `OLD-N` keys until the next hard refresh.
*
* If the workspace isn't in the cached list (first observation), we
* conservatively invalidate — the prefix is effectively "new" relative to
* what's cached, so any issues already loaded under the old prefix would
* be stale anyway.
*/
export function applyWorkspaceUpdatedToCache(
qc: QueryClient,
payload: WorkspaceUpdatedPayload,
): void {
const next = payload.workspace;
if (next?.id) {
const cached =
qc
.getQueryData<Workspace[]>(workspaceKeys.list())
?.find((w) => w.id === next.id) ?? null;
if (!cached || cached.issue_prefix !== next.issue_prefix) {
qc.invalidateQueries({ queryKey: issueKeys.all(next.id) });
}
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}
/**
* Invalidates all workspace-scoped queries. Used after reconnect and when a
* new WSClient instance is detected (workspace switch) to recover events
@@ -119,6 +151,7 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
@@ -177,12 +210,35 @@ export function useRealtimeSync(
},
agent: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
// Squad members status is derived per agent, so any agent
// change (status flip, archive, runtime swap) needs to refresh
// the per-squad members-status cache. Prefix-matches both the
// squad list and every squadMemberStatus query.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
// Creating/deleting the user's first owned agent flips
// `has_my_agent`, which gates the "my agent" chip's
// disabled-but-selected state. Refresh the resource-availability
// probe so the chip un-greys (or greys) on the first relationship
// change instead of waiting for reload.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
member: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
// Member adds/removes can flip `has_my_squad` (user joining or
// leaving a squad as a human member). Mirror the agent handler.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
// workspace:updated is handled by the specific handler below
// (compares prefixes to decide whether to also invalidate issues).
// This generic fallback still fires for workspace:deleted (paired
// with the specific navigation handler) and any future workspace:*
// events without dedicated handlers.
workspace: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
@@ -200,6 +256,10 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
// squad:deleted triggers assignee transfer — refresh issues too.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
// Creating/deleting a squad the user is involved in flips
// `has_my_squad`. Refresh resource-availability so the
// "my squad" chip's disabled state reacts in realtime.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
label: () => {
@@ -220,7 +280,14 @@ export function useRealtimeSync(
},
daemon: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
// Runtime online/offline transitions move the derived status
// for every agent that hosts on this runtime, which shifts the
// working/idle/offline pill on the squad page. Same prefix
// invalidation pattern as the agent handler above.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
}
},
autopilot: () => {
const wsId = getCurrentWsId();
@@ -266,6 +333,10 @@ export function useRealtimeSync(
// shape as the tasks invalidation above — any task lifecycle
// event shifts the aggregated usage numbers.
qc.invalidateQueries({ queryKey: ["issues", "usage"] });
// Squad members-status reads the same task lifecycle to flip
// working ↔ idle for each agent member. Prefix-matches every
// mounted squad-page's members-status query in O(1).
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
},
};
@@ -284,7 +355,9 @@ export function useRealtimeSync(
// Event types handled by specific handlers below -- skip generic refresh
const specificEvents = new Set([
"workspace:updated",
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"inbox:batch-read", "inbox:batch-archived",
"comment:created", "comment:updated", "comment:deleted",
"comment:resolved", "comment:unresolved",
"activity:created",
@@ -328,6 +401,13 @@ export function useRealtimeSync(
if (issue.status) {
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
}
// The inbox row's `assignee_scope` is derived from the issue's
// assignee, so any issue:updated event may have shifted it (the
// payload doesn't tell us which fields changed). Invalidate the
// inbox list and scope counts so chip filtering, chip badges, and
// scope-targeted bulk actions reflect the new scope without
// requiring a full reload.
onInboxInvalidate(qc, wsId);
}
});
@@ -413,6 +493,21 @@ export function useRealtimeSync(
});
});
// Bulk mark-all-read / archive-* on another device — refresh this device's
// inbox so the change appears. The payload carries `scope` (and for
// archived, `operation`) per RFC v3 §C.5 / v4 §1; precise cache updates
// off those fields are a documented follow-up — invalidate is the safe
// baseline today.
const unsubInboxBatchRead = ws.on("inbox:batch-read", () => {
const wsId = getCurrentWsId();
if (wsId) onInboxBatch(qc, wsId);
});
const unsubInboxBatchArchived = ws.on("inbox:batch-archived", () => {
const wsId = getCurrentWsId();
if (wsId) onInboxBatch(qc, wsId);
});
// --- Timeline event handlers (global fallback) ---
// These events are also handled granularly by useIssueTimeline when
// IssueDetail is mounted. This global handler exists to mark the
@@ -521,6 +616,10 @@ export function useRealtimeSync(
}
};
const unsubWsUpdated = ws.on("workspace:updated", (p) => {
applyWorkspaceUpdatedToCache(qc, p as WorkspaceUpdatedPayload);
});
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
// Event payload has UUID; look up slug from cached workspace list
@@ -818,6 +917,8 @@ export function useRealtimeSync(
unsubIssueDeleted();
unsubIssueLabelsChanged();
unsubInboxNew();
unsubInboxBatchRead();
unsubInboxBatchArchived();
unsubCommentCreated();
unsubCommentUpdated();
unsubCommentDeleted();
@@ -830,6 +931,7 @@ export function useRealtimeSync(
unsubIssueReactionRemoved();
unsubSubscriberAdded();
unsubSubscriberRemoved();
unsubWsUpdated();
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();

View File

@@ -14,6 +14,17 @@ export const runtimeLocalSkillsKeys = {
const POLL_INTERVAL_MS = 500;
const POLL_TIMEOUT_MS = 30_000;
// Import timeout is longer than discovery because old daemons (pre-batch) pop
// only one import per heartbeat cycle (~15s). With 10 queued imports the 10th
// can wait up to 150s in pending before being claimed, plus up to 60s for
// the daemon to actually run the import.
//
// Timeout invariant: IMPORT_POLL_TIMEOUT_MS must exceed
// runtimeLocalSkillPendingTimeout + runtimeLocalSkillRunningTimeout
// (server/internal/handler/runtime_local_skills.go).
// See also IMPORT_CONCURRENCY in packages/views/.../runtime-local-skill-import-panel.tsx
// and maxLocalSkillImportBatch in server/internal/handler/daemon.go.
const IMPORT_POLL_TIMEOUT_MS = 4 * 60_000; // 4 minutes
export async function resolveRuntimeLocalSkills(
runtimeId: string,
@@ -49,7 +60,7 @@ export async function resolveRuntimeLocalSkillImport(
let current = initial;
while (current.status === "pending" || current.status === "running") {
if (Date.now() - start > POLL_TIMEOUT_MS) {
if (Date.now() - start > IMPORT_POLL_TIMEOUT_MS) {
throw new Error("runtime local skill import timed out");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));

View File

@@ -45,6 +45,15 @@ export interface ListIssuesParams {
assignee_ids?: string[];
creator_id?: string;
project_id?: string;
/**
* Widen the assignee filter to issues where the user is the *indirect*
* assignee — assignee is one of the user's owned agents, or a squad that
* involves the user (human member / leader-via-owned-agent / agent member
* owned by the user). Direct member assignment is intentionally excluded:
* `involves_user_id` and `assignee_id=<user>` (tab "Assigned to me") produce
* disjoint result sets by construction.
*/
involves_user_id?: string;
open_only?: boolean;
}
@@ -65,6 +74,8 @@ export interface ListGroupedIssuesParams {
assignee_ids?: string[];
creator_id?: string;
project_id?: string;
/** See `ListIssuesParams.involves_user_id` — same semantics. */
involves_user_id?: string;
assignee_filters?: IssueActorRef[];
include_no_assignee?: boolean;
creator_filters?: IssueActorRef[];
@@ -112,6 +123,8 @@ export interface ListIssuesCache {
export interface SearchIssueResult extends Issue {
match_source: "title" | "description" | "comment";
matched_snippet?: string;
matched_description_snippet?: string;
matched_comment_snippet?: string;
}
export interface SearchIssuesResponse {

View File

@@ -117,3 +117,52 @@ export interface ListAutopilotRunsResponse {
runs: AutopilotRun[];
total: number;
}
// Webhook delivery enum is server-canonical. The frontend MUST `default`
// any switch on it to a generic fallback — see API Response Compatibility
// rules in CLAUDE.md. PR1 collapsed `skipped` into `dispatched` (the run
// itself carries the skip state); a future server may add new values.
export type WebhookDeliveryStatus =
| "queued"
| "dispatched"
| "rejected"
| "ignored"
| "failed";
export type WebhookSignatureStatus =
| "not_required"
| "valid"
| "invalid"
| "missing";
export interface WebhookDelivery {
id: string;
workspace_id: string;
autopilot_id: string;
trigger_id: string;
provider: string;
event: string;
dedupe_key: string | null;
dedupe_source: string | null;
signature_status: WebhookSignatureStatus;
status: WebhookDeliveryStatus;
attempt_count: number;
content_type: string | null;
response_status: number | null;
autopilot_run_id: string | null;
replayed_from_delivery_id: string | null;
error: string | null;
received_at: string;
last_attempt_at: string;
created_at: string;
// Detail-only fields. The list endpoint omits these to keep the wire
// size bounded (raw_body alone can be up to 256 KiB per delivery).
selected_headers?: Record<string, unknown> | null;
raw_body?: string | null;
response_body?: string | null;
}
export interface ListWebhookDeliveriesResponse {
deliveries: WebhookDelivery[];
total: number;
}

View File

@@ -135,11 +135,20 @@ export interface InboxArchivedPayload {
export interface InboxBatchReadPayload {
recipient_id: string;
count: number;
// Optional assignment-scope filter the originating mark-all-read was
// narrowed to (RFC v3 §C.5). When present, listeners may apply a precise
// cache update; when absent, the safe default is a full inbox invalidate.
scope?: import("./inbox").InboxFilterScope[] | null;
}
export interface InboxBatchArchivedPayload {
recipient_id: string;
count: number;
// Identifies the bulk archive variant so listeners can pick the right
// predicate for a precise cache update (RFC v4 §1). Optional for backward
// compatibility with older servers.
operation?: import("./inbox").InboxBatchArchiveOperation | null;
scope?: import("./inbox").InboxFilterScope[] | null;
}
export interface CommentCreatedPayload {

View File

@@ -21,6 +21,22 @@ export type InboxItemType =
| "quick_create_done"
| "quick_create_failed";
/**
* Inbox assignment scope buckets (RFC v3 §B). The three "my_*" values map to
* the user-selectable chips; "other" and "none" are server-internal fallback
* buckets that fill the default-no-filter view but cannot be explicitly
* filtered to.
*/
export type InboxAssigneeScope =
| "me"
| "my_agent"
| "my_squad"
| "other"
| "none";
/** User-selectable subset of InboxAssigneeScope (chips). */
export type InboxFilterScope = "me" | "my_agent" | "my_squad";
export interface InboxItem {
id: string;
workspace_id: string;
@@ -38,4 +54,26 @@ export interface InboxItem {
archived: boolean;
created_at: string;
details: Record<string, string> | null;
// Server-tagged scope of the issue this inbox item references (RFC v3 §A).
// Optional because older servers may not emit it.
issue_assignee_type?: "member" | "agent" | "squad" | null;
issue_assignee_id?: string | null;
assignee_scope?: InboxAssigneeScope | null;
}
export type InboxScopeCounts = Record<InboxAssigneeScope, number>;
export interface InboxResourceAvailability {
has_my_agent: boolean;
has_my_squad: boolean;
}
/**
* Identifies which bulk-archive endpoint produced an `inbox:batch-archived`
* WS event. Frontends use this to choose the right predicate when applying a
* precise cache update (RFC v4 §1).
*/
export type InboxBatchArchiveOperation =
| "archive_all"
| "archive_read"
| "archive_completed";

View File

@@ -49,7 +49,16 @@ export type {
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type {
InboxItem,
InboxSeverity,
InboxItemType,
InboxAssigneeScope,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
InboxBatchArchiveOperation,
} 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";
@@ -102,6 +111,10 @@ export type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
WebhookDelivery,
WebhookDeliveryStatus,
WebhookSignatureStatus,
ListWebhookDeliveriesResponse,
} from "./autopilot";
export type {
Squad,
@@ -115,4 +128,8 @@ export type {
RemoveSquadMemberRequest,
UpdateSquadMemberRoleRequest,
CreateSquadActivityLogRequest,
SquadMemberStatusValue,
SquadActiveIssueBrief,
SquadMemberStatus,
SquadMemberStatusListResponse,
} from "./squad";

View File

@@ -76,3 +76,32 @@ export interface CreateSquadActivityLogRequest {
outcome: SquadActivityOutcome;
details?: unknown;
}
// SquadMemberStatus mirrors the four-way bucket the back-end derives in
// handler/squad.go::deriveSquadMemberStatus. Kept as a string union here
// (rather than re-derived from snapshot data) so the squad page can render
// the freshest server-side judgement without re-fetching the agent
// snapshot / runtime list.
export type SquadMemberStatusValue = "working" | "idle" | "offline" | "unstable";
export interface SquadActiveIssueBrief {
issue_id: string;
identifier: string;
title: string;
issue_status: string;
}
export interface SquadMemberStatus {
member_type: SquadMemberType;
member_id: string;
// Human members are returned with status === null so the UI can render
// them in the same list without showing a status pill (v1 has no
// presence signal for humans).
status: SquadMemberStatusValue | null;
active_issues: SquadActiveIssueBrief[];
last_active_at: string | null;
}
export interface SquadMemberStatusListResponse {
members: SquadMemberStatus[];
}

View File

@@ -10,6 +10,11 @@ export const workspaceKeys = {
myInvitations: () => ["invitations", "mine"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
squads: (wsId: string) => ["workspaces", wsId, "squads"] as const,
// Per-squad member status. Lives under the workspace key tree so
// workspace switches naturally drop the cache, and so a broad
// `["workspaces", wsId, "squads"]` invalidation covers it.
squadMemberStatus: (wsId: string, squadId: string) =>
["workspaces", wsId, "squads", squadId, "members-status"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
};
@@ -52,6 +57,20 @@ export function squadListOptions(wsId: string) {
});
}
// Per-squad members status snapshot. The freshness signal is the WS task /
// agent / runtime invalidation wired in use-realtime-sync (which broadly
// invalidates `["workspaces", wsId, "squads"]`); the staleTime is a
// tab-focus safety net.
export function squadMemberStatusOptions(wsId: string, squadId: string) {
return queryOptions({
queryKey: workspaceKeys.squadMemberStatus(wsId, squadId),
queryFn: () => api.getSquadMemberStatus(squadId),
enabled: !!wsId && !!squadId,
staleTime: 30 * 1000,
refetchOnWindowFocus: true,
});
}
export function skillListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.skills(wsId),

View File

@@ -40,7 +40,7 @@ function ActorAvatar({
// Squads (a group, non-human) get a square tile so they don't read as
// a single person; everyone else stays round.
isSquad ? "rounded-md" : "rounded-full",
"bg-muted text-muted-foreground",
(!avatarUrl || imgError) && "bg-muted text-muted-foreground",
className
)}
style={{ width: size, height: size, fontSize: size * 0.45 }}

View File

@@ -122,7 +122,7 @@ function SelectItem({
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 items-center gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator

View File

@@ -60,6 +60,20 @@ export interface MarkdownProps {
* When provided, enables file card preprocessing and rendering.
*/
cdnDomain?: string
/**
* Optional override for the image renderer. When provided, replaces the
* default `<img>` with constrained sizing. The views-package wrapper uses
* this to inject the unified `<Attachment>` component so chat messages get
* the same hover toolbar / lightbox / preview-modal treatment as comments.
*/
renderImage?: (props: { src: string; alt: string }) => React.ReactNode
/**
* Optional override for the file-card renderer. When provided, replaces
* the simplified card chrome (filename + download button) with whatever
* the caller supplies. Used the same way as `renderImage` to bridge into
* the views-package `<Attachment>` component.
*/
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode
}
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
@@ -113,6 +127,8 @@ function createComponents(
onUrlClick?: (url: string) => void,
onFileClick?: (path: string) => void,
renderMention?: (props: { type: string; id: string }) => React.ReactNode,
renderImage?: (props: { src: string; alt: string }) => React.ReactNode,
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode,
): Partial<Components> {
const baseComponents: Partial<Components> = {
// FileCard: intercept <div data-type="fileCard"> from preprocessFileCards
@@ -122,6 +138,9 @@ function createComponents(
const rawHref = (node?.properties?.dataHref as string) || ''
const href = isAllowedFileCardHref(rawHref) ? rawHref : ''
const filename = (node?.properties?.dataFilename as string) || ''
if (renderFileCard) {
return <>{renderFileCard({ href, filename })}</>
}
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
@@ -143,14 +162,19 @@ function createComponents(
return <div {...props}>{children}</div>
},
// Images: render uploaded images with constrained sizing
img: ({ src, alt }) => (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
),
img: ({ src, alt }) => {
if (renderImage) {
return <>{renderImage({ src: typeof src === 'string' ? src : '', alt: alt ?? '' })}</>
}
return (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
)
},
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
@@ -384,11 +408,13 @@ export function Markdown({
onUrlClick,
onFileClick,
renderMention,
renderImage,
renderFileCard,
cdnDomain
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick, renderMention),
[mode, onUrlClick, onFileClick, renderMention]
() => createComponents(mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard),
[mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard]
)
// Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content

View File

@@ -240,7 +240,7 @@ function AvatarEditor({
if (!canEdit) {
return (
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg">
<ActorAvatar
actorType="agent"
actorId={agent.id}
@@ -271,7 +271,7 @@ function AvatarEditor({
type="button"
// rounded-lg matches the standard agent avatar treatment used in
// list rows. Avoid rounded-full — circles are reserved for humans.
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
aria-label={t(($) => $.inspector.change_avatar_aria)}

View File

@@ -73,7 +73,7 @@ export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps)
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring",
hasValue
? "border bg-muted"
? "border"
: "border border-dashed bg-muted/40 hover:bg-muted",
)}
aria-label={

View File

@@ -0,0 +1,72 @@
"use client";
/**
* AttachmentPreviewPage — full-page HTML attachment viewer.
*
* Destination for `openInNewTab` from HtmlAttachmentPreview's toolbar. The
* inline preview (HtmlAttachmentPreview) renders the same content in a 480px
* card with a hover toolbar; this is the same content edge-to-edge so the
* user can resize / interact with the document at full size.
*
* Same security posture as the inline preview: iframe sandbox is
* "allow-scripts" only — no allow-same-origin, no allow-top-navigation. The
* iframe runs in an opaque origin and cannot reach cookies, localStorage,
* parent, or top-level navigation.
*
* The route is workspace-scoped (`/{slug}/attachments/{id}/preview`) for
* tenancy isolation; the `/api/attachments/{id}/content` proxy itself is
* already auth-checked, so the slug is purely a URL contract.
*/
import { useEffect } from "react";
import { useT } from "../i18n";
import { useAttachmentHtmlText } from "../editor/hooks/use-attachment-html-text";
interface AttachmentPreviewPageProps {
attachmentId: string;
/** Optional display name. Falls back to a generic label and is only used
* for the document title — never echoed into the iframe sandbox. */
filename?: string;
}
export function AttachmentPreviewPage({
attachmentId,
filename,
}: AttachmentPreviewPageProps) {
const { t } = useT("editor");
const query = useAttachmentHtmlText(attachmentId);
// Set document.title so desktop's MutationObserver-based tab title picks
// up the filename. Web shows the same string in the browser tab.
useEffect(() => {
if (filename) document.title = filename;
}, [filename]);
const text = query.data?.text;
const isLoading = query.isLoading;
const isError = !isLoading && (!!query.error || !text);
return (
<div className="flex h-full w-full flex-col bg-background">
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{t(($) => $.attachment.preview_loading)}
</div>
) : isError ? (
<div
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
data-testid="attachment-preview-page-error"
>
{t(($) => $.attachment.preview_failed)}
</div>
) : (
<iframe
srcDoc={text}
sandbox="allow-scripts"
title={filename ?? "HTML attachment"}
className="flex-1 w-full border-0 bg-background"
/>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { AttachmentPreviewPage } from "./attachment-preview-page";

View File

@@ -56,6 +56,7 @@ import { ReadonlyContent } from "../../editor";
import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
import { WebhookPayloadPreview } from "./webhook-payload-preview";
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
import { useT } from "../../i18n";
function formatDate(date: string): string {
@@ -313,6 +314,25 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
};
const Icon = isWebhook ? Webhook : isApi ? Zap : Clock;
const showWebhookUrlRow = isWebhook && webhookUrl;
// Delete control extracted so a webhook trigger can render it inline
// with Copy / Rotate on the URL action row (where the other action
// buttons live), while schedule / api triggers — which have no URL row
// — keep it pinned to the row's top-right corner. Without this the
// trash icon visually floats above the URL action buttons because the
// outer flex uses `items-start`.
const deleteButton = (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => setConfirmOpen(true)}
title={t(($) => $.trigger_row.delete_dialog.confirm)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
);
return (
<div className="flex items-start gap-3 rounded-md border px-3 py-2">
@@ -345,7 +365,7 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
{t(($) => $.trigger_row.next_label, { date: formatDate(trigger.next_run_at) })}
</div>
)}
{isWebhook && webhookUrl && (
{showWebhookUrlRow && (
<div className="mt-1.5 flex items-center gap-1.5">
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1 text-xs font-mono text-foreground">
{webhookUrl}
@@ -369,17 +389,11 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
>
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
</Button>
{deleteButton}
</div>
)}
</div>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => setConfirmOpen(true)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
{!showWebhookUrlRow && deleteButton}
<AlertDialog open={confirmOpen} onOpenChange={(v) => { if (!v && !deleting) setConfirmOpen(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -755,6 +769,14 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
)}
</section>
{/* Webhook deliveries — only renders when at least one webhook
trigger is configured. The component does its own fetch so
schedule-only autopilots don't pay for an empty list query. */}
<WebhookDeliveriesSection
autopilotId={autopilotId}
hasWebhookTrigger={triggers.some((trig) => trig.kind === "webhook")}
/>
{/* Run History */}
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">

View File

@@ -0,0 +1,560 @@
"use client";
import { useState } from "react";
import {
CheckCircle2,
XCircle,
Loader2,
Ban,
AlertTriangle,
ShieldOff,
RotateCw,
Copy,
Check,
Webhook,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import {
autopilotDeliveriesOptions,
autopilotDeliveryOptions,
useReplayAutopilotDelivery,
} from "@multica/core/autopilots";
import { useWorkspaceId } from "@multica/core/hooks";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Badge } from "@multica/ui/components/ui/badge";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import { useT } from "../../i18n";
import type {
WebhookDelivery,
WebhookDeliveryStatus,
WebhookSignatureStatus,
} from "@multica/core/types";
// --- Status visuals -------------------------------------------------------
// Mapping is exhaustive over the current backend enum but every consumer
// site falls back to a generic "unknown" visual when the server adds a new
// value — see the API Response Compatibility rules in CLAUDE.md.
type StatusVisual = {
color: string;
icon: typeof CheckCircle2;
spin?: boolean;
};
const STATUS_VISUAL: Record<WebhookDeliveryStatus, StatusVisual> = {
queued: { color: "text-blue-500", icon: Loader2, spin: true },
dispatched: { color: "text-emerald-500", icon: CheckCircle2 },
// Signature failures and pre-flight bouncebacks land here. Read as a
// failure visually, the dialog footer explains the reason.
rejected: { color: "text-destructive", icon: ShieldOff },
// Ignored covers paused/disabled/archived autopilots — same payload was
// received but no run was created. Muted so it doesn't look like a bug.
ignored: { color: "text-muted-foreground", icon: Ban },
failed: { color: "text-destructive", icon: XCircle },
};
const UNKNOWN_VISUAL: StatusVisual = {
color: "text-muted-foreground",
icon: AlertTriangle,
};
function visualForStatus(status: string): StatusVisual {
return (STATUS_VISUAL as Record<string, StatusVisual>)[status] ?? UNKNOWN_VISUAL;
}
// --- Helpers --------------------------------------------------------------
function formatDate(value: string): string {
if (!value) return "—";
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// A delivery is replayable when (a) the server allows it (signature is not
// invalid AND the delivery itself wasn't rejected) and (b) we have something
// to replay (raw_body / received). We mirror the server's rule rather than
// rely on the response — keeping the button disabled saves a 400 round-trip.
function canReplay(delivery: WebhookDelivery): boolean {
if (delivery.signature_status === "invalid") return false;
if (delivery.status === "rejected") return false;
// `queued` deliveries are mid-flight on the server; replay would race the
// synchronous dispatch path. Once they settle, the user can replay.
if (delivery.status === "queued") return false;
return true;
}
// --- Section --------------------------------------------------------------
export function WebhookDeliveriesSection({
autopilotId,
hasWebhookTrigger,
}: {
autopilotId: string;
hasWebhookTrigger: boolean;
}) {
const { t } = useT("autopilots");
const wsId = useWorkspaceId();
const { data: deliveries = [], isLoading } = useQuery(
autopilotDeliveriesOptions(wsId, autopilotId, {
enabled: hasWebhookTrigger,
}),
);
// No webhook trigger configured → the entire section is irrelevant. We hide
// it rather than render an empty card to keep the detail page short for
// schedule-only autopilots.
if (!hasWebhookTrigger) return null;
return (
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
{t(($) => $.deliveries.section_title)}
</h2>
{isLoading ? (
<div className="space-y-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : deliveries.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
{t(($) => $.deliveries.empty)}
</div>
) : (
<div className="rounded-md border overflow-hidden">
{deliveries.map((delivery) => (
<DeliveryRow
key={delivery.id}
delivery={delivery}
autopilotId={autopilotId}
/>
))}
</div>
)}
</section>
);
}
// --- Row ------------------------------------------------------------------
function DeliveryRow({
delivery,
autopilotId,
}: {
delivery: WebhookDelivery;
autopilotId: string;
}) {
const { t } = useT("autopilots");
const [open, setOpen] = useState(false);
const visual = visualForStatus(delivery.status);
const StatusIcon = visual.icon;
const statusLabel =
t(($) => $.deliveries.status[delivery.status as WebhookDeliveryStatus]) ??
delivery.status;
const providerLabel = delivery.provider || "—";
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-accent/30 transition-colors"
>
<StatusIcon
className={cn(
"h-4 w-4 shrink-0",
visual.color,
visual.spin && "animate-spin",
)}
/>
<span className={cn("w-24 shrink-0 text-xs font-medium", visual.color)}>
{statusLabel}
</span>
<span className="w-20 shrink-0 text-xs text-muted-foreground truncate">
{providerLabel}
</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate font-mono">
{delivery.event || t(($) => $.webhook_payload.unknown_event)}
</span>
{delivery.replayed_from_delivery_id && (
<Badge variant="secondary" className="shrink-0">
<RotateCw className="h-3 w-3" />
{t(($) => $.deliveries.row.replay_badge)}
</Badge>
)}
{delivery.attempt_count > 1 && (
<Badge variant="outline" className="shrink-0">
{t(($) => $.deliveries.row.attempts, {
count: delivery.attempt_count,
})}
</Badge>
)}
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(delivery.received_at || delivery.created_at)}
</span>
</button>
{open && (
<DeliveryDetailDialog
open={open}
onOpenChange={setOpen}
autopilotId={autopilotId}
delivery={delivery}
/>
)}
</>
);
}
// --- Detail dialog --------------------------------------------------------
function DeliveryDetailDialog({
open,
onOpenChange,
autopilotId,
delivery,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
autopilotId: string;
delivery: WebhookDelivery;
}) {
const { t } = useT("autopilots");
const wsId = useWorkspaceId();
const { data: detail, isLoading } = useQuery(
autopilotDeliveryOptions(wsId, autopilotId, delivery.id, { enabled: open }),
);
// Use the detail row when loaded, otherwise the slim row from the list.
// The slim row is missing raw_body / response_body / selected_headers; the
// dialog renders skeleton placeholders for those sections while detail is
// still loading.
const full = detail ?? delivery;
const visual = visualForStatus(full.status);
const StatusIcon = visual.icon;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* max-h + overflow-y-auto: webhook bodies + headers + response can
easily exceed viewport height. Without a cap the dialog grows past
the screen edge and the bottom (e.g. Replay button) becomes
unreachable. 85vh leaves breathing room around the dialog. */}
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogTitle className="flex items-center gap-2">
<Webhook className="h-4 w-4 text-muted-foreground" />
{t(($) => $.deliveries.detail.title)}
</DialogTitle>
<div className="space-y-4 pt-1">
{/* Header row — status / provider / event */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<StatusIcon
className={cn(
"h-4 w-4 shrink-0",
visual.color,
visual.spin && "animate-spin",
)}
/>
<span className={cn("text-sm font-medium", visual.color)}>
{t(($) => $.deliveries.status[full.status as WebhookDeliveryStatus]) ??
full.status}
</span>
</div>
<Badge variant="outline">{full.provider || "—"}</Badge>
<code className="rounded bg-muted px-2 py-0.5 text-xs font-mono">
{full.event || t(($) => $.webhook_payload.unknown_event)}
</code>
<SignatureBadge status={full.signature_status as WebhookSignatureStatus} />
</div>
{/* Meta grid */}
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<MetaRow
label={t(($) => $.deliveries.detail.received_at)}
value={formatDate(full.received_at)}
/>
<MetaRow
label={t(($) => $.deliveries.detail.last_attempt_at)}
value={formatDate(full.last_attempt_at)}
/>
<MetaRow
label={t(($) => $.deliveries.detail.attempt_count)}
value={String(full.attempt_count)}
/>
<MetaRow
label={t(($) => $.deliveries.detail.response_status)}
value={full.response_status != null ? String(full.response_status) : "—"}
/>
<MetaRow
label={t(($) => $.deliveries.detail.dedupe_key)}
value={full.dedupe_key ?? "—"}
mono
/>
<MetaRow
label={t(($) => $.deliveries.detail.dedupe_source)}
value={full.dedupe_source ?? "—"}
/>
{full.content_type && (
<MetaRow
label={t(($) => $.deliveries.detail.content_type)}
value={full.content_type}
mono
/>
)}
{full.replayed_from_delivery_id && (
<MetaRow
label={t(($) => $.deliveries.detail.replayed_from)}
value={full.replayed_from_delivery_id}
mono
/>
)}
</dl>
{full.error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<div className="font-medium">
{t(($) => $.deliveries.detail.error_label)}
</div>
<div className="mt-0.5 font-mono break-all">{full.error}</div>
</div>
)}
{/* Raw body + response body + headers, all loaded lazily */}
<DetailSections detail={detail} isLoading={isLoading} />
{/* Replay button */}
<div className="flex items-center justify-between pt-2">
<ReplayHint delivery={full} />
<ReplayButton
autopilotId={autopilotId}
delivery={full}
onSuccess={() => onOpenChange(false)}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function MetaRow({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex flex-col">
<dt className="text-muted-foreground">{label}</dt>
<dd
className={cn(
"truncate text-foreground",
mono && "font-mono",
)}
title={value}
>
{value}
</dd>
</div>
);
}
function SignatureBadge({ status }: { status: WebhookSignatureStatus | string }) {
const { t } = useT("autopilots");
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
if (status === "valid") variant = "default";
else if (status === "invalid") variant = "destructive";
else if (status === "missing") variant = "secondary";
return (
<Badge variant={variant}>
{t(($) => $.deliveries.signature[status as WebhookSignatureStatus]) ?? status}
</Badge>
);
}
function DetailSections({
detail,
isLoading,
}: {
detail: WebhookDelivery | undefined;
isLoading: boolean;
}) {
const { t } = useT("autopilots");
if (isLoading && !detail) {
return (
<div className="space-y-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-16 w-full" />
</div>
);
}
if (!detail) return null;
return (
<div className="space-y-3">
{detail.raw_body && (
<CodeBlock
label={t(($) => $.deliveries.detail.raw_body)}
value={detail.raw_body}
/>
)}
{detail.selected_headers && Object.keys(detail.selected_headers).length > 0 && (
<CodeBlock
label={t(($) => $.deliveries.detail.selected_headers)}
value={JSON.stringify(detail.selected_headers, null, 2)}
/>
)}
{detail.response_body && (
<CodeBlock
label={t(($) => $.deliveries.detail.response_body)}
value={detail.response_body}
/>
)}
</div>
);
}
function CodeBlock({ label, value }: { label: string; value: string }) {
const { t } = useT("autopilots");
const [copied, setCopied] = useState(false);
// Truncate in-DOM display for very large bodies; the Copy button still
// yields the full string. 4 KiB is large enough for typical webhook
// payloads while keeping the dialog responsive.
const TRUNCATE_AT = 4096;
const isTruncated = value.length > TRUNCATE_AT;
const display = isTruncated ? value.slice(0, TRUNCATE_AT) : value;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
toast.success(t(($) => $.webhook_payload.copied));
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t(($) => $.webhook_payload.copy_failed));
}
};
return (
// min-w-0 lets this card shrink below the <pre>'s intrinsic min-content
// width — without it, a minified single-line JSON body would push the
// surrounding grid/flex cell (and the whole DialogContent) past the
// viewport edge.
<div className="min-w-0 rounded-md border bg-background">
<div className="flex items-center justify-between border-b px-3 py-1.5 text-[11px]">
<span className="font-medium text-muted-foreground">{label}</span>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 rounded px-2 py-0.5 hover:bg-accent transition-colors"
>
{copied ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
{copied
? t(($) => $.webhook_payload.copied_short)
: t(($) => $.webhook_payload.copy)}
</button>
</div>
{/* whitespace-pre-wrap keeps pretty-printed indentation but lets
long lines wrap; break-all is the only thing that breaks mid-token
(necessary for minified JSON, which has no whitespace to break at). */}
<pre className="max-h-48 overflow-auto bg-muted/40 px-3 py-2 text-xs font-mono leading-relaxed whitespace-pre-wrap break-all">
{display}
{isTruncated && (
<span className="block pt-2 text-muted-foreground/70">
{t(($) => $.webhook_payload.truncated_marker)}
</span>
)}
</pre>
</div>
);
}
function ReplayHint({ delivery }: { delivery: WebhookDelivery }) {
const { t } = useT("autopilots");
if (delivery.signature_status === "invalid") {
return (
<span className="text-xs text-muted-foreground">
{t(($) => $.deliveries.replay.disabled_invalid_signature)}
</span>
);
}
if (delivery.status === "rejected") {
return (
<span className="text-xs text-muted-foreground">
{t(($) => $.deliveries.replay.disabled_rejected)}
</span>
);
}
if (delivery.status === "queued") {
return (
<span className="text-xs text-muted-foreground">
{t(($) => $.deliveries.replay.disabled_queued)}
</span>
);
}
return null;
}
function ReplayButton({
autopilotId,
delivery,
onSuccess,
}: {
autopilotId: string;
delivery: WebhookDelivery;
onSuccess: () => void;
}) {
const { t } = useT("autopilots");
const replay = useReplayAutopilotDelivery();
const enabled = canReplay(delivery) && !replay.isPending;
const handleClick = async () => {
try {
await replay.mutateAsync({ autopilotId, deliveryId: delivery.id });
toast.success(t(($) => $.deliveries.replay.toast_success));
onSuccess();
} catch (e: unknown) {
const message =
e instanceof Error
? e.message
: t(($) => $.deliveries.replay.toast_failed);
toast.error(message);
}
};
return (
<Button
size="sm"
variant="outline"
onClick={handleClick}
disabled={!enabled}
>
<RotateCw
className={cn(
"h-3.5 w-3.5 mr-1",
replay.isPending && "animate-spin",
)}
/>
{replay.isPending
? t(($) => $.deliveries.replay.in_progress)
: t(($) => $.deliveries.replay.action)}
</Button>
);
}

View File

@@ -22,6 +22,7 @@ import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
import { taskMessagesOptions } from "@multica/core/chat/queries";
import { Markdown } from "@multica/views/common/markdown";
import { copyMarkdown } from "../../editor";
import { AttachmentList } from "../../issues/components/comment-card";
import type { AgentAvailability } from "@multica/core/agents";
import type { ChatMessage, ChatPendingTask, TaskMessagePayload, TaskFailureReason } from "@multica/core/types";
import type { ChatTimelineItem } from "@multica/core/chat";
@@ -155,8 +156,13 @@ function MessageBubble({ message, isPending }: { message: ChatMessage; isPending
* Neutralise prose's leading/trailing margin so single-line
* bubbles stay as compact as the plain-text version used to. */}
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<Markdown>{message.content}</Markdown>
<Markdown attachments={message.attachments}>{message.content}</Markdown>
</div>
<AttachmentList
attachments={message.attachments}
content={message.content}
className="mt-1.5"
/>
</div>
</div>
);
@@ -202,12 +208,16 @@ function AssistantMessage({
return (
<div className="w-full space-y-1.5">
{timeline.length > 0 ? (
<TimelineView items={timeline} />
<TimelineView items={timeline} attachments={message.attachments} />
) : (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
<Markdown attachments={message.attachments}>{message.content}</Markdown>
</div>
)}
<AttachmentList
attachments={message.attachments}
content={message.content}
/>
<MessageFooter
message={message}
timeline={timeline}
@@ -382,9 +392,11 @@ function FailureBubble({
function TimelineView({
items,
isStreaming,
attachments,
}: {
items: ChatTimelineItem[];
isStreaming?: boolean;
attachments?: import("@multica/core/types").Attachment[];
}) {
const { preface, middle, final } = splitTimeline(items);
@@ -392,15 +404,23 @@ function TimelineView({
<>
{preface.length > 0 && (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown>{preface.map((t) => t.content ?? "").join("")}</Markdown>
<Markdown attachments={attachments}>
{preface.map((t) => t.content ?? "").join("")}
</Markdown>
</div>
)}
{middle.length > 0 && (
<OuterProcessFold items={middle} defaultOpen={!!isStreaming} />
<OuterProcessFold
items={middle}
defaultOpen={!!isStreaming}
attachments={attachments}
/>
)}
{final.length > 0 && (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown>{final.map((t) => t.content ?? "").join("")}</Markdown>
<Markdown attachments={attachments}>
{final.map((t) => t.content ?? "").join("")}
</Markdown>
</div>
)}
</>
@@ -410,9 +430,11 @@ function TimelineView({
function OuterProcessFold({
items,
defaultOpen,
attachments,
}: {
items: ChatTimelineItem[];
defaultOpen?: boolean;
attachments?: import("@multica/core/types").Attachment[];
}) {
const { t } = useT("chat");
// useState seeds once at mount — subsequent renders never overwrite the
@@ -433,7 +455,7 @@ function OuterProcessFold({
<div className="mt-1 rounded-lg border bg-muted/20 p-2 space-y-0.5">
{items.map((item) =>
item.type === "text" ? (
<MiddleTextRow key={item.seq} item={item} />
<MiddleTextRow key={item.seq} item={item} attachments={attachments} />
) : (
<ItemRow key={item.seq} item={item} />
),
@@ -448,10 +470,16 @@ function OuterProcessFold({
// down-shifted (xs / muted) so it reads as part of the agent's process,
// not the final answer — the final answer renders below the fold at full
// prose size.
function MiddleTextRow({ item }: { item: ChatTimelineItem }) {
function MiddleTextRow({
item,
attachments,
}: {
item: ChatTimelineItem;
attachments?: import("@multica/core/types").Attachment[];
}) {
return (
<div className="py-0.5 text-xs text-muted-foreground prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<Markdown>{item.content ?? ""}</Markdown>
<Markdown attachments={attachments}>{item.content ?? ""}</Markdown>
</div>
);
}

View File

@@ -1,21 +1,16 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useStore } from "zustand";
import { toast } from "sonner";
import { ListTodo } from "lucide-react";
import { ListTodo, Search } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { UpdateIssueRequest } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { BOARD_STATUSES } from "@multica/core/issues/config";
import {
childIssueProgressOptions,
myIssueAssigneeGroupsOptions,
myIssueListOptions,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import {
actorIssuesViewStore,
type ActorIssuesScope,
@@ -24,13 +19,14 @@ import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-st
import { useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
import { Button } from "@multica/ui/components/ui/button";
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 { BoardView } from "../issues/components/board-view";
import { ListView } from "../issues/components/list-view";
import { BatchActionToolbar } from "../issues/components/batch-action-toolbar";
import { IssueDisplayControls } from "../issues/components/issues-header";
import { filterIssues } from "../issues/utils/filter";
import { matchesPinyin } from "../editor/extensions/pinyin-match";
import { useT } from "../i18n";
export type TaskActorType = "member" | "agent";
@@ -48,8 +44,7 @@ export function ActorIssuesPanel({
const wsId = useWorkspaceId();
const scope = useStore(actorIssuesViewStore, (s) => s.scope);
const setScope = useStore(actorIssuesViewStore, (s) => s.setScope);
const viewMode = useStore(actorIssuesViewStore, (s) => s.viewMode);
const grouping = useStore(actorIssuesViewStore, (s) => s.grouping);
const setViewMode = useStore(actorIssuesViewStore, (s) => s.setViewMode);
const statusFilters = useStore(actorIssuesViewStore, (s) => s.statusFilters);
const priorityFilters = useStore(actorIssuesViewStore, (s) => s.priorityFilters);
const assigneeFilters = useStore(actorIssuesViewStore, (s) => s.assigneeFilters);
@@ -59,11 +54,19 @@ export function ActorIssuesPanel({
const includeNoProject = useStore(actorIssuesViewStore, (s) => s.includeNoProject);
const labelFilters = useStore(actorIssuesViewStore, (s) => s.labelFilters);
const [search, setSearch] = useState("");
useClearFiltersOnWorkspaceChange(actorIssuesViewStore, wsId);
// The actor tasks panel is list-only; clear any persisted "board" state
// so list-only affordances (e.g. BatchActionToolbar) render correctly.
useEffect(() => {
setViewMode("list");
}, [setViewMode]);
useEffect(() => {
useIssueSelectionStore.getState().clear();
}, [viewMode, scope, actorType, actorId]);
}, [scope, actorType, actorId]);
const queryFilter: MyIssuesFilter = useMemo(
() =>
@@ -73,73 +76,25 @@ export function ActorIssuesPanel({
[scope, actorId],
);
const queryScope = `${actorType}:${actorId}:${scope}`;
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(() => {
const filter: AssigneeGroupedIssuesFilter = {
...queryFilter,
statuses: statusFilters.length > 0 ? statusFilters : [...BOARD_STATUSES],
priorities: priorityFilters,
assignee_filters: assigneeFilters,
include_no_assignee: includeNoAssignee,
creator_filters: creatorFilters,
project_ids: projectFilters,
include_no_project: includeNoProject,
label_ids: labelFilters,
};
if (scope === "assigned") {
filter.assignee_types = [actorType];
}
return filter;
}, [
actorType,
assigneeFilters,
creatorFilters,
includeNoAssignee,
includeNoProject,
labelFilters,
priorityFilters,
projectFilters,
queryFilter,
scope,
statusFilters,
]);
const assigneeGroupsOptions = myIssueAssigneeGroupsOptions(
wsId,
queryScope,
assigneeGroupFilter,
);
const rawIssuesQuery = useQuery({
...myIssueListOptions(wsId, queryScope, queryFilter),
enabled: !usesAssigneeBoard,
});
const assigneeGroupsQuery = useQuery({
...assigneeGroupsOptions,
enabled: usesAssigneeBoard,
});
const rawIssuesQuery = useQuery(myIssueListOptions(wsId, queryScope, queryFilter));
const rawIssues = useMemo(
() => rawIssuesQuery.data ?? [],
[rawIssuesQuery.data],
);
const groupedIssues = useMemo(
() => assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [],
[assigneeGroupsQuery.data],
);
const isLoading = usesAssigneeBoard
? assigneeGroupsQuery.isLoading
: rawIssuesQuery.isLoading;
const isLoading = rawIssuesQuery.isLoading;
const actorIssues = useMemo(
() =>
(usesAssigneeBoard ? groupedIssues : rawIssues).filter((issue) =>
rawIssues.filter((issue) =>
scope === "assigned"
? issue.assignee_type === actorType && issue.assignee_id === actorId
: issue.creator_type === actorType && issue.creator_id === actorId,
),
[actorId, actorType, groupedIssues, rawIssues, scope, usesAssigneeBoard],
[actorId, actorType, rawIssues, scope],
);
const issues = useMemo(
const filteredIssues = useMemo(
() =>
filterIssues(actorIssues, {
statusFilters,
@@ -164,6 +119,19 @@ export function ActorIssuesPanel({
],
);
const issues = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return filteredIssues;
return filteredIssues.filter((issue) => {
const title = issue.title ?? "";
return (
title.toLowerCase().includes(query) ||
issue.identifier.toLowerCase().includes(query) ||
matchesPinyin(title, query)
);
});
}, [filteredIssues, search]);
const { data: childProgressMap = new Map() } = useQuery(
childIssueProgressOptions(wsId),
);
@@ -175,29 +143,6 @@ export function ActorIssuesPanel({
return BOARD_STATUSES;
}, [statusFilters]);
const hiddenStatuses = useMemo(
() => BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)),
[visibleStatuses],
);
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.page.move_failed),
),
},
);
},
[updateIssueMutation, t],
);
if (isLoading) {
return <ActorIssuesSkeleton />;
}
@@ -205,33 +150,44 @@ export function ActorIssuesPanel({
return (
<ViewStoreProvider store={actorIssuesViewStore}>
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-1">
{SCOPE_VALUES.map((value) => (
<Tooltip key={value}>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
className={
scope === value
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground"
}
onClick={() => setScope(value)}
>
{t(($) => $.actor_issues.scope[value].label)}
</Button>
}
/>
<TooltipContent side="bottom">
{t(($) => $.actor_issues.scope[value].description)}
</TooltipContent>
</Tooltip>
))}
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex items-center gap-3">
<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
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t(($) => $.actor_issues.search_placeholder)}
className="h-8 w-64 pl-8 text-sm"
/>
</div>
<div className="flex items-center gap-1">
{SCOPE_VALUES.map((value) => (
<Tooltip key={value}>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
className={
scope === value
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground"
}
onClick={() => setScope(value)}
>
{t(($) => $.actor_issues.scope[value].label)}
</Button>
}
/>
<TooltipContent side="bottom">
{t(($) => $.actor_issues.scope[value].description)}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
<IssueDisplayControls scopedIssues={actorIssues} />
<IssueDisplayControls scopedIssues={actorIssues} hideViewToggle />
</div>
{actorIssues.length === 0 ? (
@@ -244,33 +200,23 @@ export function ActorIssuesPanel({
{t(($) => $.actor_issues.empty[scope].description)}
</p>
</div>
) : search.trim() !== "" && issues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<Search className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">{t(($) => $.actor_issues.search_empty)}</p>
</div>
) : (
<div className="flex flex-1 min-h-0 flex-col">
{viewMode === "board" ? (
<BoardView
issues={usesAssigneeBoard ? actorIssues : issues}
assigneeGroups={usesAssigneeBoard ? assigneeGroupsQuery.data?.groups : undefined}
assigneeGroupQueryKey={usesAssigneeBoard ? assigneeGroupsOptions.queryKey : undefined}
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
myIssuesScope={queryScope}
myIssuesFilter={queryFilter}
/>
) : (
<ListView
issues={issues}
visibleStatuses={visibleStatuses}
childProgressMap={childProgressMap}
myIssuesScope={queryScope}
myIssuesFilter={queryFilter}
/>
)}
<ListView
issues={issues}
visibleStatuses={visibleStatuses}
childProgressMap={childProgressMap}
myIssuesScope={queryScope}
myIssuesFilter={queryFilter}
/>
</div>
)}
{viewMode === "list" && <BatchActionToolbar />}
<BatchActionToolbar />
</div>
</ViewStoreProvider>
);
@@ -280,23 +226,19 @@ function ActorIssuesSkeleton() {
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-1">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-64 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</div>
<div className="flex flex-1 min-h-0 gap-4 overflow-hidden p-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
<div className="flex flex-1 min-h-0 flex-col gap-2 p-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
</div>

View File

@@ -7,11 +7,25 @@ import {
type RenderMode,
} from "@multica/ui/markdown";
import { useConfigStore } from "@multica/core/config";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import {
Attachment as AttachmentRenderer,
AttachmentDownloadProvider,
} from "../editor";
export type { RenderMode };
export type MarkdownProps = MarkdownBaseProps;
export interface MarkdownProps extends MarkdownBaseProps {
/**
* Attachments associated with the surrounding entity (chat message, skill
* file). When passed, the renderer resolves inline image / file-card URLs
* to full attachment records via AttachmentDownloadProvider, unlocking the
* unified hover toolbar / lightbox / preview-modal behavior used in
* editor surfaces.
*/
attachments?: AttachmentRecord[];
}
/**
* Default renderMention that delegates to IssueMentionCard for issue mentions
@@ -30,13 +44,58 @@ function defaultRenderMention({
return null;
}
function renderImage({ src, alt }: { src: string; alt: string }): React.ReactNode {
return (
<AttachmentRenderer
attachment={{
kind: "url",
url: src,
filename: alt,
// chat / skill markdown `![]()` is structurally an image. Without
// forceKind, empty/descriptive alt strings would route to the
// file-card chrome via getPreviewKind autodetect.
forceKind: "image",
}}
/>
);
}
function renderFileCard({
href,
filename,
}: {
href: string;
filename: string;
}): React.ReactNode {
return (
<AttachmentRenderer
attachment={{ kind: "url", url: href, filename }}
/>
);
}
/**
* App-level Markdown wrapper that injects IssueMentionCard via renderMention
* and cdnDomain from the config store for file card rendering.
* App-level Markdown wrapper. Injects:
* - IssueMentionCard for issue mentions
* - cdnDomain from the config store (drives fileCard preprocessing)
* - unified <Attachment> as the image / file-card renderer
* - AttachmentDownloadProvider so url → record resolution works inside
* the injected <Attachment> components
*/
export function Markdown(props: MarkdownProps): React.JSX.Element {
const cdnDomain = useConfigStore((s) => s.cdnDomain);
return <MarkdownBase renderMention={defaultRenderMention} cdnDomain={cdnDomain} {...props} />;
const { attachments, ...rest } = props;
return (
<AttachmentDownloadProvider attachments={attachments}>
<MarkdownBase
renderMention={defaultRenderMention}
renderImage={renderImage}
renderFileCard={renderFileCard}
cdnDomain={cdnDomain}
{...rest}
/>
</AttachmentDownloadProvider>
);
}
export const MemoizedMarkdown = React.memo(Markdown);

View File

@@ -17,6 +17,8 @@ import {
Cloud,
Cpu,
Filter,
ArrowDownNarrowWide,
ArrowUpNarrowWide,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
@@ -31,6 +33,7 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { ActorAvatar } from "../actor-avatar";
import { api } from "@multica/core/api";
import { useTranscriptViewStore, type TranscriptSortDirection } from "@multica/core/agents/stores";
import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent";
import { redactSecrets } from "./redact";
import type { TimelineItem } from "./build-timeline";
@@ -178,6 +181,8 @@ export function AgentTranscriptDialog({
const [agentInfo, setAgentInfo] = useState<Agent | null>(null);
const [runtimeInfo, setRuntimeInfo] = useState<AgentRuntime | null>(null);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const sortDirection = useTranscriptViewStore((s) => s.sortDirection);
const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection);
const eventRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -212,6 +217,26 @@ export function AgentTranscriptDialog({
return items.filter((item) => selectedTools.has(itemFilterKey(item)));
}, [items, selectedTools]);
// Apply user-chosen sort direction. Reverse is a pure presentation concern —
// the underlying timeline (and its seq numbers) is untouched, so copy/filter
// and segment navigation continue to work against the same data.
const displayItems = useMemo(
() => (sortDirection === "newest_first" ? [...filteredItems].reverse() : filteredItems),
[filteredItems, sortDirection],
);
// Toggling direction is a manual user action; jump the scroll container back
// to the top so the newest end of the timeline (per the chosen direction) is
// immediately visible. Avoids stranding the user mid-scroll on the wrong end.
const handleSortDirectionChange = useCallback(
(dir: typeof sortDirection) => {
if (dir === sortDirection) return;
setSortDirection(dir);
scrollContainerRef.current?.scrollTo({ top: 0 });
},
[sortDirection, setSortDirection],
);
// Fetch agent and runtime metadata when dialog opens
useEffect(() => {
if (!open) return;
@@ -249,9 +274,10 @@ export function AgentTranscriptDialog({
eventRefs.current.get(seq)?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Copy all events as text (uses filtered items)
// Copy all events as text. Use the displayed order so users get the same
// sequence they see on screen — matters when sort is set to newest-first.
const handleCopyAll = useCallback(() => {
const text = filteredItems
const text = displayItems
.map((item) => {
const label = getEventLabel(item);
const summary = getEventSummary(item);
@@ -262,7 +288,7 @@ export function AgentTranscriptDialog({
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [filteredItems]);
}, [displayItems]);
// Toggle tool filter
const toggleTool = useCallback((tool: string) => {
@@ -336,6 +362,17 @@ export function AgentTranscriptDialog({
{statusBadge}
<div className="ml-auto flex items-center gap-1">
{items.length > 1 && (
<SortDirectionToggle
value={sortDirection}
onChange={handleSortDirectionChange}
labels={{
chronological: t(($) => $.transcript.sort_chronological),
newestFirst: t(($) => $.transcript.sort_newest_first),
ariaLabel: t(($) => $.transcript.sort_label),
}}
/>
)}
{filterOptions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
@@ -449,10 +486,10 @@ export function AgentTranscriptDialog({
</div>
{/* ── Timeline progress bar ─────────────────────────────── */}
{filteredItems.length > 0 && (
{displayItems.length > 0 && (
<div className="border-b px-4 py-2.5 shrink-0">
<TimelineBar
items={filteredItems}
items={displayItems}
selectedSeq={selectedSeq}
onSegmentClick={handleSegmentClick}
/>
@@ -471,7 +508,7 @@ export function AgentTranscriptDialog({
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0"
>
{filteredItems.length === 0 ? (
{displayItems.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{isLive ? (
<div className="flex items-center gap-2">
@@ -484,7 +521,7 @@ export function AgentTranscriptDialog({
</div>
) : (
<div className="divide-y">
{filteredItems.map((item) => (
{displayItems.map((item) => (
<TranscriptEventRow
key={item.seq}
ref={(el) => {
@@ -503,6 +540,55 @@ export function AgentTranscriptDialog({
);
}
// ─── Sort direction toggle ──────────────────────────────────────────────────
interface SortDirectionToggleProps {
value: TranscriptSortDirection;
onChange: (dir: TranscriptSortDirection) => void;
labels: { chronological: string; newestFirst: string; ariaLabel: string };
}
function SortDirectionToggle({ value, onChange, labels }: SortDirectionToggleProps) {
return (
<div
role="group"
aria-label={labels.ariaLabel}
className="inline-flex items-center rounded border bg-muted/40 p-0.5 text-xs"
>
<button
type="button"
aria-pressed={value === "chronological"}
title={labels.chronological}
onClick={() => onChange("chronological")}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
value === "chronological"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<ArrowDownNarrowWide className="h-3 w-3" />
<span className="hidden sm:inline">{labels.chronological}</span>
</button>
<button
type="button"
aria-pressed={value === "newest_first"}
title={labels.newestFirst}
onClick={() => onChange("newest_first")}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
value === "newest_first"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<ArrowUpNarrowWide className="h-3 w-3" />
<span className="hidden sm:inline">{labels.newestFirst}</span>
</button>
</div>
);
}
// ─── Metadata chip ──────────────────────────────────────────────────────────
function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { BarChart3, FolderKanban, Users } from "lucide-react";
import { BarChart3, FolderKanban } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import {
@@ -12,10 +12,7 @@ import {
SelectValue,
} from "@multica/ui/components/ui/select";
import { useWorkspaceId } from "@multica/core/hooks";
import {
agentListOptions,
squadListOptions,
} from "@multica/core/workspace/queries";
import { agentListOptions } from "@multica/core/workspace/queries";
import { projectListOptions } from "@multica/core/projects/queries";
import {
dashboardUsageDailyOptions,
@@ -31,14 +28,19 @@ import {
DailyTokensChart,
DailyTimeChart,
DailyTasksChart,
WeeklyCostChart,
WeeklyTokensChart,
WeeklyTimeChart,
WeeklyTasksChart,
} from "../../runtimes/components/charts";
import { ProjectIcon } from "../../projects/components/project-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import {
TimezoneSelect,
browserTimezone,
} from "../../common/timezone-select";
import { formatTokens } from "../../runtimes/utils";
addDaysIso,
aggregateByWeek,
formatTokens,
todayIso,
} from "../../runtimes/utils";
import { useT } from "../../i18n";
import {
aggregateAgentTokens,
@@ -46,28 +48,48 @@ import {
aggregateDailyTasks,
aggregateDailyTime,
aggregateDailyTokens,
aggregateWeeklyTasks,
aggregateWeeklyTime,
computeDailyTotals,
formatDuration,
mergeAgentDashboardRows,
type AgentDashboardRow,
} from "../utils";
// One-place source of truth for the period selector. Matches the runtime
// detail page so users see the same three options across the dashboards.
// Period selector — mirrors the runtime detail page so users see the same
// option set across both dashboards. `dims` declares which dimensions each
// range is allowed in: 1d / 7d at the weekly grain collapse to a single bar,
// 180d at the daily grain is 180 unreadable bars, so each end of the range
// belongs to a single dimension. Switching dimensions resets `days` if the
// current value isn't in the new dimension's allowed set (see
// `handleDimChange` below).
//
// 1d semantic: "today" (the natural calendar day from 00:00 in UTC, matching
// the rollup's bucket_date axis), not "the last 24 hours". The client-side
// `dailyCutoffIso` filter below enforces this even at the midnight edge.
const TIME_RANGES = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
{ label: "1d", days: 1, dims: ["daily"] as const },
{ label: "7d", days: 7, dims: ["daily"] as const },
{ label: "30d", days: 30, dims: ["daily", "weekly"] as const },
{ label: "90d", days: 90, dims: ["daily", "weekly"] as const },
{ label: "180d", days: 180, dims: ["weekly"] as const },
] as const;
type TimeRange = (typeof TIME_RANGES)[number]["days"];
type Dim = "daily" | "weekly";
const DEFAULT_DAYS_BY_DIM: Record<Dim, TimeRange> = {
daily: 30,
weekly: 90,
};
function rangesForDim(dim: Dim) {
return TIME_RANGES.filter((r) => (r.dims as readonly string[]).includes(dim));
}
// Sentinel for "no project filter" — kept distinct from the empty string
// so it survives a refactor that ever lets a project be slug-keyed.
const ALL_PROJECTS = "__all__";
// Sentinel for "no squad filter" — same pattern as ALL_PROJECTS.
const ALL_SQUADS = "__all__";
// Stable references — `data ?? []` would create a new empty array on
// every render while the query is loading, which breaks useMemo's
// reference-equality dep check and trips the exhaustive-deps lint rule.
@@ -81,6 +103,14 @@ function fmtMoney(n: number): string {
return `$${n.toFixed(2)}`;
}
// Weekly aggregation is locked to UTC: the dashboard daily rollup buckets
// data by UTC `bucket_date` (and the raw fallback queries by `DATE(...)`,
// also UTC), so any other zone for client-side week boundaries would put
// cross-midnight rows into the wrong calendar week. Runtime-detail can use
// the runtime's IANA tz because its rollup is materialized in that tz; the
// workspace rollup has no equivalent, so weekly is UTC-only here.
const WEEK_TZ = "UTC";
// Local segmented control — same visual language the runtime usage section
// uses for its period / tab toggles. shadcn's Tabs is wired for full tab
// pages with ARIA semantics the compact toolbar pill doesn't need.
@@ -126,15 +156,19 @@ function Segmented<T extends string | number>({
*/
export function DashboardPage() {
const { t } = useT("usage");
const { t: tRuntimes } = useT("runtimes");
const wsId = useWorkspaceId();
const [dim, setDim] = useState<Dim>("daily");
const [days, setDays] = useState<TimeRange>(30);
const [projectValue, setProjectValue] = useState<string>(ALL_PROJECTS);
const [squadValue, setSquadValue] = useState<string>(ALL_SQUADS);
// Default to the browser's resolved zone so day-boundary buckets match the
// user's local clock on first render. Pure client-state — the rollup queries
// are zone-agnostic today; this is the UI affordance the user can pin.
const [timezone, setTimezone] = useState<string>(() => browserTimezone());
const allowedRanges = rangesForDim(dim);
const handleDimChange = (next: Dim) => {
setDim(next);
const stillAllowed = (rangesForDim(next) as readonly { days: number }[]).some(
(r) => r.days === days,
);
if (!stillAllowed) setDays(DEFAULT_DAYS_BY_DIM[next]);
};
// The user can save model prices from the runtimes page; re-render when
// they do so the dashboard reflects the new rates.
@@ -142,7 +176,6 @@ export function DashboardPage() {
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
// Validate the picked project against the current workspace's list. A
// stale UUID — left over from a project that's been deleted, or from the
@@ -155,23 +188,22 @@ export function DashboardPage() {
return projects.some((p) => p.id === projectValue) ? projectValue : null;
}, [projectValue, projects]);
// Same stale-UUID guard as projectId — see comment above.
const squadId = useMemo(() => {
if (squadValue === ALL_SQUADS) return null;
return squads.some((s) => s.id === squadValue) ? squadValue : null;
}, [squadValue, squads]);
// The weekly chart paints `ceil(days / 7)` trailing calendar weeks anchored
// at today-in-UTC. In the worst case (today = Sunday) the leftmost Monday
// sits `weekCount * 7 - 1` days back, so a vanilla `days=30` request would
// silently truncate the leftmost bucket. Over-fetch the per-date queries
// to cover the full first week; the per-agent rollups stay at `days` so
// KPI/leaderboard labels (e.g. "Tasks · 30D") keep their advertised window.
const weekCount = Math.max(1, Math.ceil(days / 7));
const chartFetchDays = dim === "weekly" ? weekCount * 7 : days;
const dailyQuery = useQuery(
dashboardUsageDailyOptions(wsId, days, projectId, squadId),
);
const byAgentQuery = useQuery(
dashboardUsageByAgentOptions(wsId, days, projectId, squadId),
);
const runTimeQuery = useQuery(
dashboardAgentRunTimeOptions(wsId, days, projectId, squadId),
dashboardUsageDailyOptions(wsId, chartFetchDays, projectId),
);
const byAgentQuery = useQuery(dashboardUsageByAgentOptions(wsId, days, projectId));
const runTimeQuery = useQuery(dashboardAgentRunTimeOptions(wsId, days, projectId));
const runTimeDailyQuery = useQuery(
dashboardRunTimeDailyOptions(wsId, days, projectId, squadId),
dashboardRunTimeDailyOptions(wsId, chartFetchDays, projectId),
);
const dailyUsage = dailyQuery.data ?? EMPTY_DAILY;
@@ -179,6 +211,26 @@ export function DashboardPage() {
const runTimeRows = runTimeQuery.data ?? EMPTY_RUNTIME;
const runTimeDailyRows = runTimeDailyQuery.data ?? EMPTY_RUNTIME_DAILY;
// Daily-aggregation surfaces (cost/tokens/time/tasks KPIs and the Daily
// trend chart) re-scope to the user-selected `days` even when we
// over-fetched for the weekly chart. UTC matches the bucket_date the
// backend filters on, so the cutoff lands on the same calendar boundary
// the rollup used. Applied in both dims so 1d strictly means "today" even
// at the midnight UTC edge where the server's wall-clock cutoff would
// otherwise include yesterday.
const dailyCutoffIso = useMemo(
() => addDaysIso(todayIso(WEEK_TZ), -(days - 1)),
[days],
);
const dailyUsageInWindow = useMemo(
() => dailyUsage.filter((u) => u.date >= dailyCutoffIso),
[dailyUsage, dailyCutoffIso],
);
const runTimeDailyInWindow = useMemo(
() => runTimeDailyRows.filter((r) => r.date >= dailyCutoffIso),
[runTimeDailyRows, dailyCutoffIso],
);
const isLoading =
dailyQuery.isLoading ||
byAgentQuery.isLoading ||
@@ -196,16 +248,46 @@ export function DashboardPage() {
runTimeDailyRows.length === 0;
// Cost / token math — re-derived when usage, days, or pricings change.
const totals = useMemo(() => computeDailyTotals(dailyUsage), [dailyUsage]);
const dailyCost = useMemo(() => aggregateDailyCost(dailyUsage), [dailyUsage]);
const dailyTokens = useMemo(() => aggregateDailyTokens(dailyUsage), [dailyUsage]);
const totals = useMemo(
() => computeDailyTotals(dailyUsageInWindow),
[dailyUsageInWindow],
);
const dailyCost = useMemo(
() => aggregateDailyCost(dailyUsageInWindow),
[dailyUsageInWindow],
);
const dailyTokens = useMemo(
() => aggregateDailyTokens(dailyUsageInWindow),
[dailyUsageInWindow],
);
const dailyTime = useMemo(
() => aggregateDailyTime(runTimeDailyRows),
[runTimeDailyRows],
() => aggregateDailyTime(runTimeDailyInWindow),
[runTimeDailyInWindow],
);
const dailyTasks = useMemo(
() => aggregateDailyTasks(runTimeDailyRows),
[runTimeDailyRows],
() => aggregateDailyTasks(runTimeDailyInWindow),
[runTimeDailyInWindow],
);
// Weekly aggregates — built from the over-fetched per-date queries so the
// leftmost trailing week always has data even when the user-selected `days`
// (e.g. 30D) is shorter than the chart's `weekCount * 7` span. Buckets are
// pre-zeroed inside the helpers, so sparse weeks render as empty bars
// instead of being dropped (MUL-2382 weekly window scoping). Locked to
// UTC so the week boundaries match the backend's UTC `bucket_date`.
const weekly = useMemo(
() => aggregateByWeek(dailyUsage, WEEK_TZ, weekCount),
[dailyUsage, weekCount],
);
const weeklyCost = weekly.weeklyCostStack;
const weeklyTokens = weekly.weeklyTokens;
const weeklyTime = useMemo(
() => aggregateWeeklyTime(runTimeDailyRows, WEEK_TZ, weekCount),
[runTimeDailyRows, weekCount],
);
const weeklyTasks = useMemo(
() => aggregateWeeklyTasks(runTimeDailyRows, WEEK_TZ, weekCount),
[runTimeDailyRows, weekCount],
);
const agentTokenRows = useMemo(
() => aggregateAgentTokens(byAgentUsage),
@@ -232,12 +314,10 @@ export function DashboardPage() {
return (
<div className="flex h-full flex-col">
{/* h-auto + min-h-12 + flex-wrap: the toolbar (project filter, range
switch, timezone select) overflows the single h-12 row on narrow
and medium widths once the timezone picker is added — letting the
right cluster wrap underneath keeps every control reachable
without an off-screen bleed. Wider viewports still render the
original single row. */}
{/* h-auto + min-h-12 + flex-wrap: the toolbar (project filter,
dimension switch, range switch) wraps on narrow viewports so every
control stays reachable. Wider viewports still render the original
single row. */}
<PageHeader className="h-auto min-h-12 flex-wrap justify-between gap-y-1.5 px-5 py-1.5 sm:py-0">
<div className="flex min-w-0 items-center gap-2">
<BarChart3 className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -249,21 +329,18 @@ export function DashboardPage() {
value={projectValue}
onChange={setProjectValue}
/>
<SquadFilter
squads={squads}
value={squadValue}
onChange={setSquadValue}
<Segmented
value={dim}
onChange={handleDimChange}
options={[
{ label: t(($) => $.dim.daily), value: "daily" as const },
{ label: t(($) => $.dim.weekly), value: "weekly" as const },
]}
/>
<Segmented
value={days}
onChange={setDays}
options={TIME_RANGES.map((r) => ({ label: r.label, value: r.days }))}
/>
<TimezoneSelect
value={timezone}
onValueChange={setTimezone}
browserSuffix={tRuntimes(($) => $.detail.timezone_browser_suffix)}
triggerClassName="rounded-md font-mono text-xs"
options={allowedRanges.map((r) => ({ label: r.label, value: r.days }))}
/>
</div>
</PageHeader>
@@ -315,14 +392,21 @@ export function DashboardPage() {
/>
</div>
{/* Daily trend chart — toggle picks Tokens / Cost / Time /
Tasks. All four share the same x-axis (date) so the user
can mentally overlay them by switching the toggle. */}
<DailyTrendBlock
{/* Trend chart — toggle picks Tokens / Cost / Time / Tasks
and the parent's dim selector decides whether the bars are
per-day or per-calendar-week. All four metrics share the
same x-axis so the user can mentally overlay them by
flipping the toggle. */}
<TrendBlock
dim={dim}
dailyCost={dailyCost}
dailyTokens={dailyTokens}
dailyTime={dailyTime}
dailyTasks={dailyTasks}
weeklyCost={weeklyCost}
weeklyTokens={weeklyTokens}
weeklyTime={weeklyTime}
weeklyTasks={weeklyTasks}
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
/>
@@ -398,81 +482,29 @@ function ProjectFilter({
);
}
function SquadFilter({
squads,
value,
onChange,
}: {
squads: { id: string; name: string }[];
value: string;
onChange: (v: string) => void;
}) {
const { t } = useT("usage");
const allLabel = t(($) => $.filter.all_squads);
const selected = squads.find((s) => s.id === value);
const selectedTitle =
value === ALL_SQUADS ? allLabel : selected?.name ?? allLabel;
return (
<Select
value={value}
onValueChange={(v) => onChange(v ?? ALL_SQUADS)}
>
<SelectTrigger size="sm" className="min-w-[160px]">
<SelectValue>
{() => (
<>
{selected ? (
<ActorAvatar
actorType="squad"
actorId={selected.id}
size={14}
profileLink={false}
/>
) : (
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{selectedTitle}</span>
</>
)}
</SelectValue>
</SelectTrigger>
{/* alignItemWithTrigger=false: same viewport-clipping reason as ProjectFilter. */}
<SelectContent align="start" alignItemWithTrigger={false} className="max-h-72">
<SelectItem value={ALL_SQUADS}>
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{allLabel}</span>
</SelectItem>
{squads.map((s) => (
<SelectItem key={s.id} value={s.id}>
<ActorAvatar
actorType="squad"
actorId={s.id}
size={14}
profileLink={false}
className="self-center"
/>
<span className="truncate">{s.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
type DailyMetric = "tokens" | "cost" | "time" | "tasks";
function DailyTrendBlock({
function TrendBlock({
dim,
dailyCost,
dailyTokens,
dailyTime,
dailyTasks,
weeklyCost,
weeklyTokens,
weeklyTime,
weeklyTasks,
lessThanMinuteLabel,
}: {
dim: Dim;
dailyCost: ReturnType<typeof aggregateDailyCost>;
dailyTokens: ReturnType<typeof aggregateDailyTokens>;
dailyTime: ReturnType<typeof aggregateDailyTime>;
dailyTasks: ReturnType<typeof aggregateDailyTasks>;
weeklyCost: ReturnType<typeof aggregateByWeek>["weeklyCostStack"];
weeklyTokens: ReturnType<typeof aggregateByWeek>["weeklyTokens"];
weeklyTime: ReturnType<typeof aggregateWeeklyTime>;
weeklyTasks: ReturnType<typeof aggregateWeeklyTasks>;
lessThanMinuteLabel: string;
}) {
const { t } = useT("usage");
@@ -481,13 +513,18 @@ function DailyTrendBlock({
// Empty-state is per-metric so each toggle option independently decides
// whether it has data — e.g. tokens recorded but no terminal runs yet
// should show Tokens normally while Time / Tasks fall through to empty.
const totalCost = dailyCost.reduce((sum, d) => sum + d.total, 0);
const totalTokens = dailyTokens.reduce(
const costData = dim === "weekly" ? weeklyCost : dailyCost;
const tokensData = dim === "weekly" ? weeklyTokens : dailyTokens;
const timeData = dim === "weekly" ? weeklyTime : dailyTime;
const tasksData = dim === "weekly" ? weeklyTasks : dailyTasks;
const totalCost = costData.reduce((sum, d) => sum + d.total, 0);
const totalTokens = tokensData.reduce(
(sum, d) => sum + d.input + d.output + d.cacheRead + d.cacheWrite,
0,
);
const totalSeconds = dailyTime.reduce((sum, d) => sum + d.totalSeconds, 0);
const totalTasks = dailyTasks.reduce(
const totalSeconds = timeData.reduce((sum, d) => sum + d.totalSeconds, 0);
const totalTasks = tasksData.reduce(
(sum, d) => sum + d.completed + d.failed,
0,
);
@@ -501,13 +538,21 @@ function DailyTrendBlock({
: totalTasks === 0;
const title =
metric === "cost"
? t(($) => $.daily.title_cost)
: metric === "tokens"
? t(($) => $.daily.title_tokens)
: metric === "time"
? t(($) => $.daily.title_time)
: t(($) => $.daily.title_tasks);
dim === "weekly"
? metric === "cost"
? t(($) => $.weekly.title_cost)
: metric === "tokens"
? t(($) => $.weekly.title_tokens)
: metric === "time"
? t(($) => $.weekly.title_time)
: t(($) => $.weekly.title_tasks)
: metric === "cost"
? t(($) => $.daily.title_cost)
: metric === "tokens"
? t(($) => $.daily.title_tokens)
: metric === "time"
? t(($) => $.daily.title_time)
: t(($) => $.daily.title_tasks);
return (
<div className="rounded-lg border bg-card p-4">
@@ -532,6 +577,20 @@ function DailyTrendBlock({
{t(($) => $.daily.no_data)}
</p>
</div>
) : dim === "weekly" ? (
metric === "cost" ? (
<WeeklyCostChart data={weeklyCost} />
) : metric === "tokens" ? (
<WeeklyTokensChart data={weeklyTokens} />
) : metric === "time" ? (
<WeeklyTimeChart
data={weeklyTime}
formatY={(s) => formatDuration(s, lessThanMinuteLabel)}
formatTooltip={(s) => formatDuration(s, lessThanMinuteLabel)}
/>
) : (
<WeeklyTasksChart data={weeklyTasks} />
)
) : metric === "cost" ? (
<DailyCostChart data={dailyCost} />
) : metric === "tokens" ? (

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
aggregateAgentTokens,
aggregateDailyCost,
aggregateWeeklyTasks,
aggregateWeeklyTime,
computeDailyTotals,
formatDuration,
mergeAgentDashboardRows,
@@ -211,3 +213,95 @@ describe("formatDuration", () => {
expect(formatDuration(0.4, "<1m")).toBe("<1m");
});
});
// ---------------------------------------------------------------------------
// Weekly run-time / tasks aggregation. Mirrors the runtimes-side
// aggregateByWeek tests: trailing N calendar weeks anchored at today-in-tz,
// pre-zeroed buckets, partial-week metadata, and rows outside the window
// dropped. We assert the same invariants on the workspace dashboard helpers
// so all four metrics behave consistently when the user toggles Weekly.
// ---------------------------------------------------------------------------
describe("aggregateWeeklyTime", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("folds per-day run-time rows into Mon-anchored weekly totals", () => {
// 2026-05-19 is a Tuesday → current week is Mon=05-18..Sun=05-24.
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
const rows = [
{ date: "2026-05-11", total_seconds: 100, task_count: 0, failed_count: 0 },
{ date: "2026-05-17", total_seconds: 50, task_count: 0, failed_count: 0 },
{ date: "2026-05-18", total_seconds: 25, task_count: 0, failed_count: 0 },
];
const result = aggregateWeeklyTime(rows, "UTC", 2);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
weekStart: "2026-05-11",
weekEnd: "2026-05-17",
totalSeconds: 150,
partial: false,
daysCovered: 7,
});
expect(result[1]).toMatchObject({
weekStart: "2026-05-18",
totalSeconds: 25,
partial: true,
daysCovered: 2, // Mon + Tue
});
});
it("drops rows that fall outside the trailing window and keeps empty buckets", () => {
// Same MUL-2382 sparse-data regression we caught on the runtimes side:
// an old populated week must not surface when the requested window
// doesn't include it; in-range empty weeks must remain as zero buckets.
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
const rows = [
// 2026-04-13 is a Monday — exactly one week earlier than the oldest
// in-range week (Mon=04-20) for a 5-week trailing window.
{ date: "2026-04-13", total_seconds: 999, task_count: 0, failed_count: 0 },
];
const result = aggregateWeeklyTime(rows, "UTC", 5);
expect(result.map((w) => w.weekStart)).toEqual([
"2026-04-20",
"2026-04-27",
"2026-05-04",
"2026-05-11",
"2026-05-18",
]);
for (const w of result) expect(w.totalSeconds).toBe(0);
});
});
describe("aggregateWeeklyTasks", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("splits completed and failed counts per calendar week", () => {
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
const rows = [
{ date: "2026-05-12", total_seconds: 0, task_count: 5, failed_count: 1 },
{ date: "2026-05-18", total_seconds: 0, task_count: 3, failed_count: 0 },
];
const result = aggregateWeeklyTasks(rows, "UTC", 2);
expect(result[0]).toMatchObject({
weekStart: "2026-05-11",
completed: 4,
failed: 1,
});
expect(result[1]).toMatchObject({
weekStart: "2026-05-18",
completed: 3,
failed: 0,
partial: true,
});
});
});

View File

@@ -4,10 +4,20 @@ import type {
DashboardAgentRunTime,
DashboardRunTimeDaily,
} from "@multica/core/types";
import { estimateCost, estimateCostBreakdown, type DailyTokenData } from "../runtimes/utils";
import {
addDaysIso,
estimateCost,
estimateCostBreakdown,
formatShortDate,
todayIso,
weekStartIso,
type DailyTokenData,
} from "../runtimes/utils";
import type {
DailyTimeData,
DailyTasksData,
WeeklyTimeData,
WeeklyTasksData,
} from "../runtimes/components/charts";
// ---------------------------------------------------------------------------
@@ -217,6 +227,103 @@ export function mergeAgentDashboardRows(
});
}
// ---------------------------------------------------------------------------
// Weekly fold for run-time + tasks. Mirrors `aggregateByWeek` in
// `runtimes/utils.ts` which already covers cost / tokens — same calendar
// week semantics (MonSun anchored at today-in-tz), same pre-zeroed buckets,
// same partial-week metadata. Workspace dashboard uses the user-chosen
// timezone here; the runtime page uses the runtime's IANA tz. Behaviour is
// identical apart from where the tz comes from.
// ---------------------------------------------------------------------------
interface WeekShell {
weekStart: string;
weekEnd: string;
label: string;
rangeLabel: string;
partial: boolean;
daysCovered: number;
}
// Build N trailing calendar week shells anchored at today-in-tz. Each shell
// carries the labels and partial-week metadata the chart components consume;
// downstream aggregators fold their own per-week values onto the matching
// shell.
function buildWeekShells(tz: string, weekCount: number): WeekShell[] {
const count = Math.max(1, Math.floor(weekCount));
const today = todayIso(tz);
const currentWeekStart = weekStartIso(today);
const firstWeekStart = addDaysIso(currentWeekStart, -(count - 1) * 7);
const shells: WeekShell[] = [];
for (let i = 0; i < count; i++) {
const weekStart = addDaysIso(firstWeekStart, i * 7);
const weekEnd = addDaysIso(weekStart, 6);
const partial = today < weekEnd;
// Inclusive count of how many days of this week have actually elapsed.
// Closed weeks sit at 7; the current week reports 1..6.
const clampedToday =
today < weekStart ? weekStart : today < weekEnd ? today : weekEnd;
const elapsed = Math.min(7, Math.max(1, diffDaysIso(weekStart, clampedToday) + 1));
shells.push({
weekStart,
weekEnd,
label: formatShortDate(weekStart),
rangeLabel: `${formatShortDate(weekStart)} ${formatShortDate(weekEnd)}`,
partial,
daysCovered: partial ? elapsed : 7,
});
}
return shells;
}
function diffDaysIso(from: string, to: string): number {
const [y1, m1, d1] = from.split("-").map(Number);
const [y2, m2, d2] = to.split("-").map(Number);
const a = Date.UTC(y1 ?? 1970, (m1 ?? 1) - 1, d1 ?? 1);
const b = Date.UTC(y2 ?? 1970, (m2 ?? 1) - 1, d2 ?? 1);
return Math.round((b - a) / 86_400_000);
}
export function aggregateWeeklyTime(
rows: DashboardRunTimeDaily[],
tz: string,
weekCount: number,
): WeeklyTimeData[] {
const shells = buildWeekShells(tz, weekCount);
const totals = new Map<string, number>();
for (const shell of shells) totals.set(shell.weekStart, 0);
for (const r of rows) {
const wkStart = weekStartIso(r.date);
if (!totals.has(wkStart)) continue;
totals.set(wkStart, (totals.get(wkStart) ?? 0) + r.total_seconds);
}
return shells.map((s) => ({ ...s, totalSeconds: totals.get(s.weekStart) ?? 0 }));
}
export function aggregateWeeklyTasks(
rows: DashboardRunTimeDaily[],
tz: string,
weekCount: number,
): WeeklyTasksData[] {
const shells = buildWeekShells(tz, weekCount);
const buckets = new Map<string, { completed: number; failed: number }>();
for (const shell of shells)
buckets.set(shell.weekStart, { completed: 0, failed: 0 });
for (const r of rows) {
const wkStart = weekStartIso(r.date);
const bucket = buckets.get(wkStart);
if (!bucket) continue;
const failed = r.failed_count;
const completed = Math.max(0, r.task_count - failed);
bucket.completed += completed;
bucket.failed += failed;
}
return shells.map((s) => {
const b = buckets.get(s.weekStart) ?? { completed: 0, failed: 0 };
return { ...s, completed: b.completed, failed: b.failed };
});
}
// Per-date run-time rows → one row per date with `totalSeconds` for the
// DailyTimeChart. Sorted ascending so the x-axis reads oldest-to-newest,
// matching the cost / tokens aggregators.

View File

@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
image: { download: "Download" },
attachment: {
preview: "Preview",
preview_loading: "Loading preview…",
},
file_card: { uploading: "Uploading {{filename}}" },
}),
}),
}));
import { AttachmentCard } from "./attachment-card";
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
describe("AttachmentCard — chrome row", () => {
it("renders chrome only and never an inline iframe (HTML rich preview lives in HtmlAttachmentPreview)", () => {
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByText("report.html")).toBeTruthy();
expect(document.querySelector("iframe")).toBeNull();
});
it("hides the Eye button for an html URL-only source (the modal's /content proxy is ID-keyed)", () => {
// Regression: a cross-comment / copy-pasted `!file[report.html](url)`
// used to surface a dead Eye button — text kinds need an attachmentId,
// otherwise tryOpen rejects and the click becomes a silent no-op.
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.queryByTitle("Preview")).toBeNull();
// Download stays available — the underlying URL is still reachable.
expect(screen.getByTitle("Download")).toBeTruthy();
});
it("shows the Eye button for an html source when an attachmentId is available", () => {
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByTitle("Preview")).toBeTruthy();
});
it("shows the Eye button for a URL-only pdf source (modal renders pdfs directly from URL)", () => {
// Counterpart to the html regression: media kinds (pdf/video/audio)
// ARE URL-previewable because the modal renders them via
// <iframe src=url>/<video>/<audio>, not via the /content proxy.
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
href="https://cdn.example/manual.pdf"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByTitle("Preview")).toBeTruthy();
});
});
describe("AttachmentCard — Eye / Download buttons", () => {
it("invokes onPreview when Eye is clicked", () => {
const onPreview = vi.fn();
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
attachmentId="att-1"
href="https://cdn.example/manual.pdf"
onPreview={onPreview}
onDownload={() => {}}
/>,
);
fireEvent.mouseDown(screen.getByTitle("Preview"));
expect(onPreview).toHaveBeenCalled();
});
it("invokes onDownload when Download is clicked", () => {
const onDownload = vi.fn();
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
attachmentId="att-1"
href="https://cdn.example/manual.pdf"
onPreview={() => {}}
onDownload={onDownload}
/>,
);
fireEvent.mouseDown(screen.getByTitle("Download"));
expect(onDownload).toHaveBeenCalled();
});
it("hides Eye and Download buttons while uploading", () => {
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
uploading
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.queryByTitle("Preview")).toBeNull();
expect(screen.queryByTitle("Download")).toBeNull();
// The mock `t()` returns the i18n template as-is; the production t-fn
// interpolates {{filename}} → "report.html". Asserting the template
// proves the uploading branch was selected without depending on the
// interpolation behavior of the mock.
expect(screen.getByText("Uploading {{filename}}")).toBeTruthy();
});
});

View File

@@ -0,0 +1,138 @@
"use client";
/**
* AttachmentCard — shared file-card row UI (icon + filename + Eye + Download).
*
* Subcomponent of the unified `<Attachment>` dispatcher (see attachment.tsx).
* Rendered for every attachment kind that does not have a richer inline
* renderer (image / html). Kind-aware routing lives in `<Attachment>` — keep
* that decision out of this file so this stays a single-purpose row UI.
*/
import { Download, Eye, FileText, Loader2 } from "lucide-react";
import { useT } from "../i18n";
import { getPreviewKind } from "./utils/preview";
interface AttachmentCardChromeProps {
filename: string;
uploading?: boolean;
canPreview: boolean;
canDownload: boolean;
onPreview: () => void;
onDownload: () => void;
}
function AttachmentCardChrome({
filename,
uploading,
canPreview,
canDownload,
onPreview,
onDownload,
}: AttachmentCardChromeProps) {
const { t } = useT("editor");
return (
<div
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
onMouseDown={(e) => e.stopPropagation()}
>
{uploading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<FileText className="size-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm">
{uploading
? t(($) => $.file_card.uploading, { filename })
: filename}
</p>
</div>
{!uploading && canPreview && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onPreview();
}}
>
<Eye className="size-3.5" />
</button>
)}
{!uploading && canDownload && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onDownload();
}}
>
<Download className="size-3.5" />
</button>
)}
</div>
);
}
export interface AttachmentCardProps {
/** Filename used for icon label and previewable-kind detection. */
filename: string;
/** Content type used in addition to filename for previewable-kind detection. */
contentType?: string;
/**
* Attachment id — required when the preview proxy is ID-keyed (text kinds
* like markdown / html / text). Media kinds (pdf/video/audio) preview from
* the URL alone.
*/
attachmentId?: string;
/** Download URL — used as a non-null sentinel for the download button. */
href?: string;
/** True while a synchronous upload is in flight (file-card NodeView only). */
uploading?: boolean;
/** Pressed when the Eye button is clicked. */
onPreview: () => void;
/** Pressed when the Download button is clicked. */
onDownload: () => void;
}
export function AttachmentCard({
filename,
contentType = "",
attachmentId,
href,
uploading,
onPreview,
onDownload,
}: AttachmentCardProps) {
const kind = filename ? getPreviewKind(contentType, filename) : null;
// Media kinds (pdf/video/audio) are previewable from a URL alone — the
// modal renders them as <video>/<audio>/<iframe src=url>. Text kinds
// (markdown/html/text) need the ID-keyed `/api/attachments/{id}/content`
// proxy, so they only preview when we have an attachmentId — otherwise
// the Eye button would call tryOpen, get rejected, and do nothing.
const isUrlPreviewableKind =
kind === "pdf" || kind === "video" || kind === "audio";
const canPreview =
!!href && kind !== null && (!!attachmentId || isUrlPreviewableKind);
return (
<div className="my-1">
<AttachmentCardChrome
filename={filename}
uploading={uploading}
canPreview={canPreview}
canDownload={!!href}
onPreview={onPreview}
onDownload={onDownload}
/>
</div>
);
}

View File

@@ -50,6 +50,37 @@ vi.mock("./use-download-attachment", () => ({
useDownloadAttachment: () => downloadMock,
}));
// Module-level flags toggled per-test: simulate desktop (openInNewTab
// adapter present) vs web (omitted), and the no-slug case where the
// modal sits outside a workspace route.
const { openInNewTabMock, getShareableUrlMock, navState, slugState } =
vi.hoisted(() => ({
openInNewTabMock: vi.fn(),
getShareableUrlMock: vi.fn((p: string) => `https://app.example${p}`),
navState: { hasOpenInNewTab: true },
slugState: { value: "acme" as string | null },
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
...(navState.hasOpenInNewTab ? { openInNewTab: openInNewTabMock } : {}),
getShareableUrl: getShareableUrlMock,
}),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => slugState.value,
};
});
// ReadonlyContent has a heavy import surface (lowlight + KaTeX + Mermaid).
// Stub it so the markdown dispatch test only verifies wiring.
vi.mock("./readonly-content", () => ({
@@ -71,6 +102,7 @@ vi.mock("../i18n", () => ({
preview_unsupported: "This file type can't be previewed.",
close: "Close",
download_failed: "",
open_in_new_tab: "Open in new tab",
},
}),
}),
@@ -113,6 +145,8 @@ function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
beforeEach(() => {
vi.clearAllMocks();
navState.hasOpenInNewTab = true;
slugState.value = "acme";
});
afterEach(() => {
@@ -120,6 +154,28 @@ afterEach(() => {
});
describe("AttachmentPreviewModal — dispatch", () => {
it("renders an <img> centered in the modal for image content types", () => {
const att = makeAttachment({ filename: "shot.png", content_type: "image/png" });
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
const img = document.querySelector("img");
expect(img).toBeTruthy();
expect(img?.getAttribute("src")).toBe(att.download_url);
expect(img?.getAttribute("alt")).toBe(att.filename);
});
it("renders an <img> from a URL-only source for image filenames", () => {
const url = "https://cdn.example.test/orphan.png?Signature=s";
render(
<AttachmentPreviewModal
source={{ kind: "url", url, filename: "orphan.png" }}
open
onClose={() => {}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(url);
});
it("renders a PDF iframe pointing at the signed download URL", () => {
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
@@ -159,7 +215,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
expect(screen.getByTestId("readonly-content").textContent).toContain("# heading");
});
it("renders an iframe with srcdoc + sandbox='' for HTML", async () => {
it("renders an iframe with srcdoc + sandbox='allow-scripts' for HTML", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
@@ -170,7 +226,10 @@ describe("AttachmentPreviewModal — dispatch", () => {
await waitFor(() => {
const frame = document.querySelector("iframe[sandbox]") as HTMLIFrameElement | null;
expect(frame).toBeTruthy();
expect(frame?.getAttribute("sandbox")).toBe("");
// `allow-scripts` is required so vanilla-JS chart libraries render
// (MUL-2330). The combination with `allow-same-origin` would defeat
// the sandbox, so this assertion must stay exact.
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame?.getAttribute("srcdoc")).toBe("<p>hi</p>");
});
});
@@ -315,6 +374,114 @@ describe("AttachmentPreviewModal — URL-only source", () => {
});
});
describe("AttachmentPreviewModal — open-in-new-tab (HTML only)", () => {
it("renders the open-in-new-tab button in the header for HTML attachments", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const att = makeAttachment({
filename: "report.html",
content_type: "text/html",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
expect(screen.getByTitle("Open in new tab")).toBeTruthy();
});
it("invokes navigation.openInNewTab with the preview path when available (desktop)", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const att = makeAttachment({
filename: "report.html",
content_type: "text/html",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByTitle("Open in new tab"));
expect(openInNewTabMock).toHaveBeenCalledWith(
"/acme/attachments/att-1/preview?name=report.html",
"report.html",
);
});
it("falls back to window.open against the shareable URL on web", async () => {
navState.hasOpenInNewTab = false;
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const windowOpenSpy = vi
.spyOn(window, "open")
.mockImplementation(() => null);
const att = makeAttachment({
filename: "report.html",
content_type: "text/html",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByTitle("Open in new tab"));
expect(openInNewTabMock).not.toHaveBeenCalled();
expect(windowOpenSpy).toHaveBeenCalledWith(
"https://app.example/acme/attachments/att-1/preview?name=report.html",
"_blank",
"noopener,noreferrer",
);
});
it("does not render the new-tab button for non-HTML kinds", () => {
const att = makeAttachment({
filename: "manual.pdf",
content_type: "application/pdf",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
expect(screen.queryByTitle("Open in new tab")).toBeNull();
});
it("does not render the new-tab button when there is no workspace slug", async () => {
slugState.value = null;
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const att = makeAttachment({
filename: "report.html",
content_type: "text/html",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
expect(screen.queryByTitle("Open in new tab")).toBeNull();
});
});
describe("useAttachmentPreview — tryOpen gate", () => {
it("accepts a full attachment for a media kind", () => {
const { result } = renderHook(() => useAttachmentPreview());

View File

@@ -3,9 +3,10 @@
/**
* AttachmentPreviewModal — full-screen inline preview for an attachment.
*
* Sibling to the existing `ImageLightbox` (extensions/image-view.tsx) which
* keeps owning images. This modal handles 6 other PreviewKinds:
* Single modal for every previewable kind. Handles 7 PreviewKinds:
*
* - image : <img className="object-contain"> centered in the modal frame.
* Replaces the previous standalone ImageLightbox.
* - pdf : <iframe src={download_url}> — relies on Chromium's PDFium
* plugin. On desktop, requires webPreferences.plugins=true
* (see apps/desktop/src/main/index.ts).
@@ -15,10 +16,12 @@
* - markdown : fetch text via api.getAttachmentTextContent, render via
* the existing ReadonlyContent (full mention/mermaid/katex
* pipeline included).
* - html : fetch text, hand to <iframe srcdoc={text} sandbox="">.
* Empty sandbox attribute = max restriction (no scripts,
* no forms, no top-nav, no popups, no same-origin) — the
* recommended pattern for previewing untrusted HTML.
* - html : fetch text, hand to <iframe srcdoc={text}
* sandbox="allow-scripts">. The iframe runs in an opaque
* origin: scripts execute (chart libraries / vanilla SVG
* JS work), but cookie / localStorage / parent access /
* top-navigation / popups / forms stay blocked because
* `allow-same-origin` is intentionally NOT included.
* - text : fetch text, highlight with lowlight if the extension
* maps to a known hljs language; otherwise plain <pre>.
*
@@ -35,19 +38,15 @@ import {
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { Download, FileText, Loader2, X } from "lucide-react";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { cn } from "@multica/ui/lib/utils";
import {
api,
PreviewTooLargeError,
PreviewUnsupportedError,
} from "@multica/core/api";
import { Download, ExternalLink, FileText, Loader2, X } from "lucide-react";
import type { Attachment } from "@multica/core/types";
import { paths, useWorkspaceSlug } from "@multica/core/paths";
import { useT } from "../i18n";
import { useNavigation } from "../navigation";
import { openExternal } from "../platform";
import { ReadonlyContent } from "./readonly-content";
import {
@@ -56,6 +55,8 @@ import {
type PreviewKind,
} from "./utils/preview";
import { useDownloadAttachment } from "./use-download-attachment";
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
import { CodeBlockStatic } from "./code-block-static";
// ---------------------------------------------------------------------------
// Preview source — full attachment, or URL-only (media types only)
@@ -77,7 +78,7 @@ export type PreviewSource =
// PreviewKinds that can render from a URL-only source. Text-based kinds
// (markdown / html / text) need the /content proxy which is ID-keyed.
const URL_ONLY_KINDS = new Set<PreviewKind>(["pdf", "video", "audio"]);
const URL_ONLY_KINDS = new Set<PreviewKind>(["image", "pdf", "video", "audio"]);
// Normalized view used everywhere downstream of `useAttachmentPreview`.
// `attachmentId === null` signals URL-only mode (download falls back to
@@ -180,6 +181,10 @@ export function AttachmentPreviewModal({
const { t } = useT("editor");
const download = useDownloadAttachment();
const state = normalize(source);
// useWorkspaceSlug (not useWorkspacePaths) — returns null outside a
// workspace route instead of throwing, so the new-tab button just hides.
const slug = useWorkspaceSlug();
const navigation = useNavigation();
useEffect(() => {
if (!open) return;
@@ -203,6 +208,25 @@ export function AttachmentPreviewModal({
}
};
// Open-in-new-tab mirrors HtmlAttachmentPreview's inline toolbar: only the
// `html` kind has a dedicated full-page route (/attachments/{id}/preview).
// Gated on slug + attachmentId for the same reason — URL-only sources
// can't address the /content proxy the page relies on.
const canOpenInNewTab = kind === "html" && !!slug && !!state.attachmentId;
const handleOpenInNewTab = () => {
if (!slug || !state.attachmentId) return;
const nameQuery = state.filename
? `?name=${encodeURIComponent(state.filename)}`
: "";
const path = `${paths.workspace(slug).attachmentPreview(state.attachmentId)}${nameQuery}`;
if (navigation.openInNewTab) {
navigation.openInNewTab(path, state.filename);
return;
}
const url = navigation.getShareableUrl(path);
window.open(url, "_blank", "noopener,noreferrer");
};
if (!open || typeof document === "undefined") return null;
return createPortal(
@@ -228,6 +252,17 @@ export function AttachmentPreviewModal({
{state.contentType || "—"}
</span>
<div className="ml-auto flex items-center gap-1">
{canOpenInNewTab && (
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.open_in_new_tab)}
aria-label={t(($) => $.attachment.open_in_new_tab)}
onClick={handleOpenInNewTab}
>
<ExternalLink className="size-4" />
</button>
)}
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
@@ -309,6 +344,16 @@ function PreviewContent({
}
switch (kind) {
case "image":
return (
<div className="flex h-full w-full items-center justify-center bg-black/40 p-4">
<img
src={state.mediaUrl}
alt={state.filename}
className="max-h-full max-w-full rounded-lg object-contain"
/>
</div>
);
case "pdf":
return (
<iframe
@@ -355,7 +400,12 @@ function PreviewContent({
render={(text) => (
<iframe
srcDoc={text}
sandbox=""
// `allow-scripts` without `allow-same-origin` — scripts run
// in an opaque origin and cannot read cookies / localStorage
// / parent state, nor escape via top-nav / popups / forms.
// Required so JS-driven charts (echarts / Plotly / vanilla
// SVG injection) render instead of showing a blank `<svg>`.
sandbox="allow-scripts"
className="h-full w-full bg-background"
title={state.filename}
/>
@@ -368,7 +418,11 @@ function PreviewContent({
attachmentId={state.attachmentId!}
onDownload={onDownload}
render={(text) => (
<CodeBlock language={extensionToLanguage(state.filename)} body={text} />
<CodeBlockStatic
language={extensionToLanguage(state.filename)}
body={text}
className="px-6 py-4"
/>
)}
/>
);
@@ -393,19 +447,7 @@ function TextBackedPreview({
render: (text: string) => ReactNode;
}) {
const { t } = useT("editor");
const query = useQuery({
queryKey: ["attachment-content", attachmentId] as const,
queryFn: () => api.getAttachmentTextContent(attachmentId),
// Errors are surfaced as typed fallbacks, not retried — 413 / 415 won't
// become 200 on a retry, and a transient failure is easier to recover
// from by closing and reopening the modal than waiting on background
// retries that have no UI affordance.
retry: false,
// 413 / 415 bodies are tiny; keep the result around for the session so
// the user can flip away and back without refetching.
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
});
const query = useAttachmentHtmlText(attachmentId);
if (query.isLoading) {
return (
@@ -443,44 +485,6 @@ function TextBackedPreview({
return <>{render(query.data.text)}</>;
}
// ---------------------------------------------------------------------------
// Code block — lowlight, matches readonly-content's hljs CSS
// ---------------------------------------------------------------------------
const lowlight = createLowlight(common);
function CodeBlock({ language, body }: { language: string | undefined; body: string }) {
const html = useMemo(() => {
const code = body.replace(/\n$/, "");
try {
const tree = language
? lowlight.highlight(language, code)
: lowlight.highlightAuto(code);
return toHtml(tree) as string;
} catch {
// Fallthrough to a plain escaped <pre> when lowlight rejects the
// language tag. Avoids crashing the preview on an unknown extension.
return escapeHtml(code);
}
}, [body, language]);
return (
<pre className="rich-text-editor m-0 overflow-auto px-6 py-4 text-sm">
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ---------------------------------------------------------------------------
// Fallback — used for 413 / 415 / unknown kinds
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,298 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
const {
getAttachmentTextContentMock,
downloadMock,
openExternalMock,
openByUrlMock,
} = vi.hoisted(() => ({
getAttachmentTextContentMock: vi.fn(),
downloadMock: vi.fn(),
openExternalMock: vi.fn(),
openByUrlMock: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: { getAttachmentTextContent: getAttachmentTextContentMock },
PreviewTooLargeError: class extends Error {},
PreviewUnsupportedError: class extends Error {},
}));
vi.mock("./use-download-attachment", () => ({
useDownloadAttachment: () => downloadMock,
}));
vi.mock("../platform", () => ({
openExternal: openExternalMock,
}));
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
image: {
view: "View",
download: "Download",
copy_link: "Copy link",
copy_link_failed: "Copy failed",
link_copied: "Link copied",
delete: "Delete",
},
attachment: {
preview: "Preview",
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
preview_unsupported: "This file type can't be previewed.",
preview_too_large: "File is too large to preview.",
open_in_new_tab: "Open in new tab",
close: "Close",
},
file_card: { uploading: "Uploading {{filename}}" },
}),
}),
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
openInNewTab: vi.fn(),
getShareableUrl: (p: string) => `https://app.example${p}`,
}),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
useWorkspacePaths: () => actual.paths.workspace("acme"),
};
});
// Resolver mock — feeds the test-scoped attachments[] into the
// useAttachmentDownloadResolver hook the component reads.
const resolverState: { attachments: AttachmentRecord[] } = { attachments: [] };
vi.mock("./attachment-download-context", () => ({
useAttachmentDownloadResolver: () => ({
resolveAttachmentId: (url: string) =>
resolverState.attachments.find((a) => a.url === url)?.id,
resolveAttachment: (url: string) =>
resolverState.attachments.find((a) => a.url === url),
openByUrl: openByUrlMock,
}),
AttachmentDownloadProvider: ({ children }: { children: ReactNode }) =>
<>{children}</>,
}));
import { Attachment } from "./attachment";
function makeRecord(overrides: Partial<AttachmentRecord> = {}): AttachmentRecord {
return {
id: "att-1",
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "u-1",
filename: "shot.png",
url: "https://cdn.example.test/att-1.png",
download_url: "https://cdn.example.test/att-1.png?Signature=s",
content_type: "image/png",
size_bytes: 1024,
created_at: "2026-05-13T00:00:00Z",
...overrides,
};
}
function renderWithQuery(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => {
vi.clearAllMocks();
resolverState.attachments = [];
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Attachment — image dispatch", () => {
it("record image renders <img> with hover toolbar (View/Download/Copy)", () => {
const att = makeRecord();
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
const img = document.querySelector("img");
expect(img).toBeTruthy();
expect(img?.getAttribute("src")).toBe(att.url);
expect(img?.getAttribute("alt")).toBe("shot.png");
expect(screen.getByTitle("View")).toBeTruthy();
expect(screen.getByTitle("Download")).toBeTruthy();
expect(screen.getByTitle("Copy link")).toBeTruthy();
// Trash only shows in editable mode.
expect(screen.queryByTitle("Delete")).toBeNull();
});
it("editable image shows Trash button and wires onDelete", () => {
const att = makeRecord();
const onDelete = vi.fn();
renderWithQuery(
<Attachment
attachment={{ kind: "record", attachment: att }}
editable
onDelete={onDelete}
/>,
);
const trash = screen.getByTitle("Delete");
fireEvent.click(trash);
expect(onDelete).toHaveBeenCalled();
});
it("url-only image resolves to a record via context and uses its id for download", () => {
const att = makeRecord({
filename: "from-resolver.png",
url: "https://cdn.example.test/from-resolver.png",
});
resolverState.attachments = [att];
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: att.url,
filename: "from-resolver.png",
}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe(att.url);
fireEvent.click(screen.getByTitle("Download"));
expect(downloadMock).toHaveBeenCalledWith("att-1");
});
it("forceKind=image renders as image even when filename is empty (markdown ![](url) regression)", () => {
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "https://external.example/no-ext-here",
filename: "",
forceKind: "image",
}}
/>,
);
// Without forceKind the empty filename would fall through to AttachmentCard.
// With forceKind="image" it must render as an <img>.
expect(document.querySelector("img")).toBeTruthy();
expect(screen.queryByText("Uploading")).toBeNull();
});
it("external image (no resolver match) renders <img> and falls back to openByUrl on Download", () => {
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "https://external.example/foo.png",
filename: "foo.png",
}}
/>,
);
const img = document.querySelector("img");
expect(img?.getAttribute("src")).toBe("https://external.example/foo.png");
fireEvent.click(screen.getByTitle("Download"));
expect(openByUrlMock).toHaveBeenCalledWith("https://external.example/foo.png");
expect(downloadMock).not.toHaveBeenCalled();
});
it("uploading image renders no toolbar (loader state)", () => {
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "blob://local",
filename: "in-flight.png",
uploading: true,
}}
/>,
);
expect(screen.queryByTitle("View")).toBeNull();
expect(screen.queryByTitle("Download")).toBeNull();
});
});
describe("Attachment — html dispatch", () => {
it("record html with attachmentId renders HtmlAttachmentPreview (no file-card chrome)", () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart</p>",
originalContentType: "text/html",
});
const att = makeRecord({
filename: "report.html",
content_type: "text/html",
url: "https://cdn.example.test/report.html",
});
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
// HtmlAttachmentPreview hides the filename row.
expect(screen.queryByText("report.html")).toBeNull();
expect(screen.getByTitle("Preview")).toBeTruthy();
expect(screen.getByTitle("Download")).toBeTruthy();
});
it("url-only html (no resolver match) falls back to AttachmentCard chrome", () => {
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "https://external.example/report.html",
filename: "report.html",
contentType: "text/html",
}}
/>,
);
// Without an attachment id the /content proxy is unreachable, so we
// show the chrome instead of the iframe.
expect(screen.getByText("report.html")).toBeTruthy();
expect(document.querySelector("iframe")).toBeNull();
});
});
describe("Attachment — file-card dispatch", () => {
it("record pdf renders the file-card chrome (filename + Preview/Download)", () => {
const att = makeRecord({
filename: "manual.pdf",
content_type: "application/pdf",
});
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
expect(screen.getByText("manual.pdf")).toBeTruthy();
expect(document.querySelector("iframe")).toBeNull();
expect(document.querySelector("img")).toBeNull();
});
it("uploading file-card surfaces the uploading template, no Preview/Download", () => {
renderWithQuery(
<Attachment
attachment={{
kind: "url",
url: "blob://local",
filename: "in-flight.zip",
uploading: true,
}}
/>,
);
expect(screen.getByText("Uploading {{filename}}")).toBeTruthy();
// Preview/Download chrome is hidden while uploading.
expect(screen.queryByTitle("Preview")).toBeNull();
expect(screen.queryByTitle("Download")).toBeNull();
});
});

View File

@@ -0,0 +1,342 @@
"use client";
/**
* Attachment — single unified renderer for every attachment surface.
*
* Takes one attachment-shaped input (a full record, a URL-only reference, or
* an in-flight upload) and dispatches by PreviewKind:
*
* - image → ImageAttachmentView (figure + hover toolbar + lightbox via
* the shared AttachmentPreviewModal)
* - html → HtmlAttachmentPreview (inline iframe + hover toolbar)
* - others → AttachmentCard (icon + filename + Eye/Download row)
*
* Call sites:
* - extensions/file-card.tsx FileCardView (Tiptap NodeView)
* - extensions/image-view.tsx ImageView (Tiptap NodeView)
* - readonly-content.tsx (markdown img + fileCard div renderers)
* - issues/components/comment-card.tsx AttachmentList (standalone fallback)
* - common/markdown.tsx (chat / skill viewer Markdown wrapper)
*
* The component owns its own preview modal and download dispatcher — callers
* just pass `attachment` and (for editor surfaces) optional editor chrome
* hints (selected, editable, onDelete).
*/
import {
Download,
Link as LinkIcon,
Maximize2,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { useT } from "../i18n";
import { useAttachmentDownloadResolver } from "./attachment-download-context";
import { useAttachmentPreview } from "./attachment-preview-modal";
import { useDownloadAttachment } from "./use-download-attachment";
import { AttachmentCard } from "./attachment-card";
import { HtmlAttachmentPreview } from "./html-attachment-preview";
import { getPreviewKind, type PreviewKind } from "./utils/preview";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export type AttachmentInput =
// Server response in hand — full record. Used by AttachmentList and any
// caller iterating a server-returned attachments[] array.
| { kind: "record"; attachment: AttachmentRecord }
// Markdown / Tiptap inline: only a URL + filename. Resolves to a full
// record via the surrounding AttachmentDownloadProvider when available;
// otherwise renders in URL-only mode (media types still preview from URL,
// text types fall back to a download CTA).
| {
kind: "url";
url: string;
filename: string;
contentType?: string;
/** Editor in-flight state. Renders a loader placeholder. */
uploading?: boolean;
/**
* Structural hint from the call site: "this slot is definitionally an
* image / file / ...". Bypasses `getPreviewKind` autodetect, which
* needs a filename or content-type and falls back to the file-card
* chrome when neither is available. Required for callers that KNOW
* the kind from context (markdown `![]()` is always an image; Tiptap
* image NodeView is always an image) but receive only a URL with an
* empty `alt`/`filename`.
*/
forceKind?: PreviewKind;
};
export interface AttachmentProps {
attachment: AttachmentInput;
/** Editor hint — when true, the image toolbar exposes Trash. */
editable?: boolean;
/** Editor hint — applies the "selected" visual to the image figure. */
selected?: boolean;
/** Editor hint — wired to Tiptap deleteNode(). */
onDelete?: () => void;
className?: string;
}
interface Normalized {
filename: string;
contentType: string;
url: string;
attachmentId?: string;
record?: AttachmentRecord;
uploading: boolean;
}
function normalize(
input: AttachmentInput,
resolve: (url: string) => AttachmentRecord | undefined,
): Normalized {
if (input.kind === "record") {
return {
filename: input.attachment.filename,
contentType: input.attachment.content_type,
url: input.attachment.url,
attachmentId: input.attachment.id,
record: input.attachment,
uploading: false,
};
}
const record = input.url ? resolve(input.url) : undefined;
return {
filename: input.filename || record?.filename || "",
contentType: input.contentType || record?.content_type || "",
url: input.url,
attachmentId: record?.id,
record,
uploading: !!input.uploading,
};
}
// ---------------------------------------------------------------------------
// Dispatcher
// ---------------------------------------------------------------------------
export function Attachment({
attachment,
editable,
selected,
onDelete,
className,
}: AttachmentProps) {
const { resolveAttachment, openByUrl } = useAttachmentDownloadResolver();
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
const state = normalize(attachment, resolveAttachment);
const forceKind =
attachment.kind === "url" ? attachment.forceKind : undefined;
const kind =
forceKind ??
(state.filename || state.contentType
? getPreviewKind(state.contentType, state.filename)
: null);
const openPreview = () => {
if (state.record) {
preview.tryOpen({ kind: "full", attachment: state.record });
return;
}
if (state.url) {
preview.tryOpen({
kind: "url",
url: state.url,
filename: state.filename,
});
}
};
const handleDownload = () => {
if (state.attachmentId) {
download(state.attachmentId);
return;
}
if (state.url) openByUrl(state.url);
};
if (kind === "image") {
return (
<>
<ImageAttachmentView
src={state.url}
alt={state.filename}
uploading={state.uploading}
editable={editable}
selected={selected}
onView={openPreview}
onDownload={handleDownload}
onDelete={onDelete}
className={className}
/>
{preview.modal}
</>
);
}
if (kind === "html" && state.attachmentId && !state.uploading) {
return (
<>
<HtmlAttachmentPreview
attachmentId={state.attachmentId}
filename={state.filename}
onPreview={openPreview}
onDownload={handleDownload}
/>
{preview.modal}
</>
);
}
return (
<>
<AttachmentCard
filename={state.filename}
contentType={state.contentType}
attachmentId={state.attachmentId}
href={state.url || undefined}
uploading={state.uploading}
onPreview={openPreview}
onDownload={handleDownload}
/>
{preview.modal}
</>
);
}
// ---------------------------------------------------------------------------
// ImageAttachmentView — inline image with hover toolbar
// ---------------------------------------------------------------------------
//
// Self-contained Tailwind: works inside the editor surface (where the legacy
// `.rich-text-editor .image-figure` CSS in content-editor.css continues to
// apply for backward compatibility) AND in standalone surfaces (chat
// messages, comment-card AttachmentList) that don't carry that scope.
interface ImageAttachmentViewProps {
src: string;
alt: string;
uploading: boolean;
editable?: boolean;
selected?: boolean;
onView: () => void;
onDownload: () => void;
onDelete?: () => void;
className?: string;
}
function ImageAttachmentView({
src,
alt,
uploading,
editable,
selected,
onView,
onDownload,
onDelete,
className,
}: ImageAttachmentViewProps) {
const { t } = useT("editor");
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(src);
toast.success(t(($) => $.image.link_copied));
} catch {
toast.error(t(($) => $.image.copy_link_failed));
}
};
// Click on figure opens the preview only in non-editor surfaces — inside
// the editor we let ProseMirror own the click for selection / cursor
// placement and route preview through the explicit Maximize button.
const figureOnClick = !editable && !uploading ? onView : undefined;
return (
<span
className={cn(
"image-node group/image relative inline-block max-w-full",
className,
)}
>
<span
className={cn(
"image-figure relative inline-block max-w-full rounded-md transition-shadow",
selected && editable && "image-selected ring-2 ring-primary",
!editable && !uploading && "cursor-zoom-in",
)}
onClick={figureOnClick}
>
{src ? (
<img
src={src}
alt={alt}
className={cn(
"image-content block max-w-full rounded-md",
uploading && "image-uploading opacity-60",
)}
draggable={false}
/>
) : (
// Defensive: an image input without a URL is degenerate, but
// emitting nothing leaves no anchor for the toolbar. Render a
// small placeholder so the surface is still recognizable.
<span className="block h-20 w-32 rounded-md bg-muted" />
)}
{!uploading && src && (
<span
className="image-toolbar absolute right-2 top-2 flex items-center gap-0.5 rounded-md border border-border bg-background/95 p-0.5 opacity-0 shadow-sm transition-opacity group-hover/image:opacity-100"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t(($) => $.image.view)}
aria-label={t(($) => $.image.view)}
onClick={onView}
>
<Maximize2 className="size-3.5" />
</button>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={onDownload}
>
<Download className="size-3.5" />
</button>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t(($) => $.image.copy_link)}
aria-label={t(($) => $.image.copy_link)}
onClick={handleCopyLink}
>
<LinkIcon className="size-3.5" />
</button>
{editable && onDelete && (
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t(($) => $.image.delete)}
aria-label={t(($) => $.image.delete)}
onClick={onDelete}
>
<Trash2 className="size-3.5" />
</button>
)}
</span>
)}
</span>
</span>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
/**
* Shared HTML preview iframe.
*
* Used by:
* - InlineHtmlIframe inside AttachmentCard (HTML attachments inline preview)
* - CodeBlockView for fenced ```html blocks (editable Tiptap NodeView)
* - HtmlBlockPreview for fenced ```html blocks (ReadonlyContent)
* - AttachmentPreviewModal's full-screen HTML kind
*
* Sandbox semantics:
* sandbox="allow-scripts" (NOT "allow-same-origin")
* → iframe runs in an opaque origin: scripts execute (chart JS works),
* but cookie / localStorage / parent access / top-nav / popups / forms
* remain blocked. This is the standard "preview untrusted HTML" model
* (HTML spec §iframe sandbox, MDN, Claude artifacts, v0.dev preview).
*
* The server-side `text/plain` + `nosniff` defense at
* /api/attachments/{id}/content remains untouched — we only feed iframe.srcDoc
* the text body we fetched, never point iframe.src at the proxy URL.
*/
import { cn } from "@multica/ui/lib/utils";
interface CodeBlockIframeProps {
/** Document source for srcDoc. Empty string renders a blank frame. */
html: string;
/** Iframe title for accessibility. */
title: string;
className?: string;
/** Tailwind height token; defaults to h-[320px]. */
heightClassName?: string;
}
export function CodeBlockIframe({
html,
title,
className,
heightClassName = "h-[320px]",
}: CodeBlockIframeProps) {
return (
<iframe
// srcDoc keeps the body in the parent's process but isolated to an
// opaque origin via sandbox. Critical that we never combine
// `allow-scripts` with `allow-same-origin` — that pairing defeats the
// sandbox per the HTML spec (notes on the sandbox attribute).
srcDoc={html}
sandbox="allow-scripts"
title={title}
className={cn(
"w-full rounded-md border border-border bg-background",
heightClassName,
className,
)}
/>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
/**
* CodeBlockStatic — read-only lowlight-highlighted code block.
*
* Used by:
* - AttachmentPreviewModal's text-kind fallback (extracted from there).
* - HtmlBlockPreview's "source" toggle in ReadonlyContent.
*
* NOT used by Tiptap's editable code-block NodeView: that path must keep
* `<NodeViewContent as="code" />` so the user can continue typing into the
* code block. Replacing it with a static lowlight component would freeze
* the content and desync ProseMirror state from the DOM.
*/
import { useMemo } from "react";
import { createLowlight, common } from "lowlight";
import { toHtml } from "hast-util-to-html";
import { cn } from "@multica/ui/lib/utils";
const lowlight = createLowlight(common);
interface CodeBlockStaticProps {
language: string | undefined;
body: string;
className?: string;
}
export function CodeBlockStatic({ language, body, className }: CodeBlockStaticProps) {
const html = useMemo(() => {
const code = body.replace(/\n$/, "");
try {
const tree = language
? lowlight.highlight(language, code)
: lowlight.highlightAuto(code);
return toHtml(tree) as string;
} catch {
// Unknown language tag — fall back to escaped plain text so we don't
// crash on an esoteric extension.
return escapeHtml(code);
}
}, [body, language]);
return (
<pre className={cn("rich-text-editor m-0 overflow-auto text-sm", className)}>
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
);
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

View File

@@ -315,6 +315,12 @@
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
/* XML / HTML — lowlight emits .hljs-tag for `<` `>` brackets and .hljs-name
for the element name. Without these rules, HTML source renders mostly in
the default text color and looks unhighlighted. */
.rich-text-editor .hljs-tag { color: var(--muted-foreground); }
.rich-text-editor .hljs-name { color: oklch(0.55 0.16 255); }
/* Dark mode overrides */
.dark .rich-text-editor .hljs-keyword,
.dark .rich-text-editor .hljs-selector-tag,
@@ -341,6 +347,8 @@
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
.dark .rich-text-editor .hljs-name { color: oklch(0.72 0.14 255); }
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;

View File

@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
// Tiptap's NodeView primitives are hard to instantiate in jsdom without a
// full editor. Stub them so the test can render <CodeBlockView /> as a plain
// React component and inspect the resulting DOM shape.
vi.mock("@tiptap/react", () => {
const NodeViewWrapper = ({ children, ...rest }: any) => (
<div data-testid="nvw" {...rest}>
{children}
</div>
);
// The real NodeViewContent renders an element managed by ProseMirror. For
// the test it's enough to surface a sentinel element so we can assert it
// remains mounted while CSS-hidden.
const NodeViewContent = ({ as = "div", ...rest }: any) => {
const Tag = as;
return <Tag data-testid="nvc" {...rest} />;
};
return { NodeViewWrapper, NodeViewContent };
});
vi.mock("../mermaid-diagram", () => ({
MermaidDiagram: () => null,
}));
vi.mock("../../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
code_block: {
copy_code: "Copy code",
show_preview: "Show preview",
show_source: "Show source",
},
}),
}),
}));
import { CodeBlockView } from "./code-block-view";
function makeProps(language: string, text: string) {
return {
node: {
attrs: { language },
textContent: text,
},
} as unknown as Parameters<typeof CodeBlockView>[0];
}
describe("CodeBlockView — html language toggle", () => {
// Inner async timers in useDebouncedValue make the iframe srcDoc lag by
// ~200ms; use fake timers so the test stays deterministic.
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("defaults to preview view: renders an iframe with sandbox='allow-scripts' and keeps the <pre> mounted (hidden)", () => {
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
act(() => {
vi.advanceTimersByTime(250);
});
const frame = document.querySelector("iframe");
expect(frame).toBeTruthy();
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
// NodeViewContent (and its enclosing <pre>) MUST remain mounted —
// unmounting would break Tiptap's bindings and prevent editing.
const nvc = screen.getByTestId("nvc");
expect(nvc).toBeTruthy();
const pre = nvc.closest("pre");
expect(pre).toBeTruthy();
expect(pre?.className).toContain("sr-only");
});
it("toggles to source view: iframe is removed and the <pre> is no longer hidden", () => {
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
act(() => {
vi.advanceTimersByTime(250);
});
expect(document.querySelector("iframe")).toBeTruthy();
const toggle = screen.getByTitle("Show source");
fireEvent.click(toggle);
expect(document.querySelector("iframe")).toBeNull();
const nvc = screen.getByTestId("nvc");
const pre = nvc.closest("pre")!;
expect(pre.className).not.toContain("sr-only");
});
it("does not show the toggle or an iframe for a non-html language", () => {
render(<CodeBlockView {...makeProps("typescript", "const x = 1;")} />);
expect(screen.queryByTitle("Show source")).toBeNull();
expect(screen.queryByTitle("Show preview")).toBeNull();
expect(document.querySelector("iframe")).toBeNull();
});
});

View File

@@ -3,16 +3,22 @@
import { useEffect, useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
import { Code as CodeIcon, Copy, Check, Eye } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
import { MermaidDiagram } from "../mermaid-diagram";
import { CodeBlockIframe } from "../code-block-iframe";
// Coalesces fast keystrokes before re-rendering the Mermaid preview.
// Coalesces fast keystrokes before re-rendering live previews.
// `mermaid.initialize()` mutates a process-global config, so back-to-back
// renders during typing can race a concurrent ReadonlyContent render
// (e.g. a comment card) and clobber its theme variables. 200ms keeps the
// "live preview" feel while making concurrent inits unlikely in practice.
const MERMAID_PREVIEW_DEBOUNCE_MS = 200;
// HTML preview reuses the same debounce: re-keying iframe.srcDoc on every
// keystroke causes the iframe to re-load and flicker.
const PREVIEW_DEBOUNCE_MS = 200;
const HTML_PREVIEW_HEIGHT = "h-[320px]";
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
@@ -26,12 +32,22 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
function CodeBlockView({ node }: NodeViewProps) {
const { t } = useT("editor");
const [copied, setCopied] = useState(false);
// HTML blocks default to "preview"; the user can flip to "source" to
// edit the markup directly. Note: the source `<pre>` MUST stay mounted
// (just hidden) so ProseMirror keeps its NodeView bindings — unmounting
// it would break editing.
const [view, setView] = useState<"preview" | "source">("preview");
const language = node.attrs.language || "";
const isMermaid = language === "mermaid";
const isHtml = language === "html";
const chart = node.textContent;
const debouncedChart = useDebouncedValue(
isMermaid ? chart : "",
MERMAID_PREVIEW_DEBOUNCE_MS,
PREVIEW_DEBOUNCE_MS,
);
const debouncedHtml = useDebouncedValue(
isHtml ? chart : "",
PREVIEW_DEBOUNCE_MS,
);
const handleCopy = async () => {
@@ -42,6 +58,10 @@ function CodeBlockView({ node }: NodeViewProps) {
setTimeout(() => setCopied(false), 2000);
};
const showHtmlPreview = isHtml && view === "preview";
const toggleView = () =>
setView((v) => (v === "preview" ? "source" : "preview"));
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
{isMermaid && debouncedChart.trim() && (
@@ -52,6 +72,18 @@ function CodeBlockView({ node }: NodeViewProps) {
<MermaidDiagram chart={debouncedChart} />
</div>
)}
{isHtml && showHtmlPreview && (
// CSS-hidden when toggled off so the `<pre>` below stays mounted —
// unmounting either side would either lose ProseMirror bindings
// (source) or thrash iframe.srcDoc (preview).
<div contentEditable={false} className="mb-1">
<CodeBlockIframe
html={debouncedHtml}
title="HTML preview"
heightClassName={HTML_PREVIEW_HEIGHT}
/>
</div>
)}
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
@@ -61,6 +93,29 @@ function CodeBlockView({ node }: NodeViewProps) {
{language}
</span>
)}
{isHtml && (
<button
type="button"
onClick={toggleView}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={
view === "preview"
? t(($) => $.code_block.show_source)
: t(($) => $.code_block.show_preview)
}
aria-label={
view === "preview"
? t(($) => $.code_block.show_source)
: t(($) => $.code_block.show_preview)
}
>
{view === "preview" ? (
<CodeIcon className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
)}
<button
type="button"
onClick={handleCopy}
@@ -74,7 +129,14 @@ function CodeBlockView({ node }: NodeViewProps) {
)}
</button>
</div>
<pre spellCheck={false}>
{/* `<pre>` + NodeViewContent must remain mounted so the user can keep
editing the code block contents. When the HTML preview is showing
we just visually hide it — ProseMirror still tracks it. */}
<pre
spellCheck={false}
className={cn(showHtmlPreview && "sr-only")}
aria-hidden={showHtmlPreview ? "true" : undefined}
>
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
<NodeViewContent as="code" />
</pre>

View File

@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Tiptap NodeView primitives can't be instantiated without a full editor.
// Stub the wrapper so FileCardView renders as a plain React component and
// the DOM can be inspected directly.
vi.mock("@tiptap/react", () => ({
NodeViewWrapper: ({ children, ...rest }: any) => <div {...rest}>{children}</div>,
}));
const { getAttachmentTextContentMock, resolveAttachmentMock, openByUrlMock, tryOpenMock } =
vi.hoisted(() => ({
getAttachmentTextContentMock: vi.fn(),
resolveAttachmentMock: vi.fn(),
openByUrlMock: vi.fn(),
tryOpenMock: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: { getAttachmentTextContent: getAttachmentTextContentMock },
PreviewTooLargeError: class extends Error {},
PreviewUnsupportedError: class extends Error {},
}));
vi.mock("../attachment-download-context", () => ({
useAttachmentDownloadResolver: () => ({
openByUrl: openByUrlMock,
resolveAttachment: resolveAttachmentMock,
}),
}));
vi.mock("../attachment-preview-modal", () => ({
useAttachmentPreview: () => ({ tryOpen: tryOpenMock, open: vi.fn(), modal: null }),
}));
// HtmlAttachmentPreview (the kind="html" route through AttachmentBlock) now
// reads useNavigation() + useWorkspaceSlug() for its Open-in-new-tab button.
// Provide minimal mocks so the component renders without a real provider.
vi.mock("../../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
openInNewTab: vi.fn(),
getShareableUrl: (p: string) => `https://app.example${p}`,
}),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
};
});
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
image: { download: "Download" },
attachment: {
preview: "Preview",
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
open_in_new_tab: "Open in new tab",
},
code_block: { copy_code: "Copy code" },
file_card: { uploading: "Uploading {{filename}}" },
}),
}),
}));
import { FileCardView } from "./file-card";
function renderWithQuery(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
describe("FileCardView — HTML attachment routes through AttachmentBlock to iframe", () => {
// Regression pin for file-card.tsx:59. The NodeView must render through
// <AttachmentBlock>, not the older <AttachmentCard>. If someone reverts that
// line, the dispatcher's html+attachmentId branch is bypassed and the user
// is left with the file-card chrome — exactly the bug MUL-2330 surfaced.
it("renders an iframe (no file-card chrome) when the node resolves to an HTML attachment", async () => {
resolveAttachmentMock.mockReturnValue({
id: "att-1",
content_type: "text/html",
url: "/uploads/report.html",
filename: "report.html",
});
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart</p>",
originalContentType: "text/html",
});
const node = {
attrs: {
href: "/uploads/report.html",
filename: "report.html",
uploading: false,
},
} as any;
renderWithQuery(<FileCardView node={node} {...({} as any)} />);
const frame = await waitFor(() => {
const f = document.querySelector("iframe") as HTMLIFrameElement | null;
expect(f).toBeTruthy();
return f!;
});
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame.getAttribute("srcdoc")).toContain("<p>chart</p>");
// The AttachmentCard chrome surfaces the filename as text inside its row.
// HtmlAttachmentPreview replaces the chrome entirely, so the filename
// must not appear as visible text.
expect(screen.queryByText("report.html")).toBeNull();
});
});

View File

@@ -17,12 +17,8 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Eye, FileText, Loader2, Download } from "lucide-react";
import { FILE_CARD_URL_PATTERN } from "@multica/ui/markdown";
import { useT } from "../../i18n";
import { useAttachmentDownloadResolver } from "../attachment-download-context";
import { useAttachmentPreview } from "../attachment-preview-modal";
import { getPreviewKind } from "../utils/preview";
import { Attachment } from "../attachment";
const FILE_CARD_MARKDOWN_RE = new RegExp(
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`,
@@ -30,94 +26,21 @@ const FILE_CARD_MARKDOWN_RE = new RegExp(
// ---------------------------------------------------------------------------
// Helpers
// React NodeView — thin wrapper, all rendering lives in <Attachment>
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// React NodeView
// ---------------------------------------------------------------------------
function FileCardView({ node }: NodeViewProps) {
const { t } = useT("editor");
export function FileCardView({ node }: NodeViewProps) {
const href = (node.attrs.href as string) || "";
const filename = (node.attrs.filename as string) || "";
const uploading = node.attrs.uploading as boolean;
const { openByUrl, resolveAttachment } = useAttachmentDownloadResolver();
const preview = useAttachmentPreview();
const openFile = () => {
openByUrl(href);
};
// Preview gate mirrors the Download gate (href is enough). We attempt
// to resolve the full Attachment from the surrounding provider, but its
// absence is no longer fatal — media kinds (pdf/video/audio) only need
// the URL, so they remain previewable even when `resolveAttachment`
// misses (e.g. the URL was copy-pasted across comments and isn't in the
// current entity's attachments). Text kinds still require the id because
// the preview proxy is ID-keyed.
const attachment = href ? resolveAttachment(href) : undefined;
const kind = filename
? getPreviewKind(attachment?.content_type ?? "", filename)
: null;
const isMediaKind = kind === "pdf" || kind === "video" || kind === "audio";
const canPreview = !!href && kind !== null && (!!attachment || isMediaKind);
const openPreview = () => {
if (attachment) {
preview.tryOpen({ kind: "full", attachment });
} else if (href) {
preview.tryOpen({ kind: "url", url: href, filename });
}
};
return (
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
<div
className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
contentEditable={false}
onMouseDown={(e) => e.stopPropagation()}
>
{uploading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<FileText className="size-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{uploading ? t(($) => $.file_card.uploading, { filename }) : filename}</p>
</div>
{!uploading && canPreview && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
openPreview();
}}
>
<Eye className="size-3.5" />
</button>
)}
{!uploading && href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
openFile();
}}
>
<Download className="size-3.5" />
</button>
)}
<div contentEditable={false}>
<Attachment
attachment={{ kind: "url", url: href, filename, uploading }}
/>
</div>
{preview.modal}
</NodeViewWrapper>
);
}

View File

@@ -1,148 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
/**
* ImageView — Tiptap NodeView for the image node.
*
* Thin wrapper around the unified `<Attachment>` dispatcher. All rendering
* (figure, hover toolbar, lightbox/preview) lives there. The NodeView only
* forwards Tiptap's editor-context hints (editable, selected, deleteNode).
*/
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import {
Maximize2,
Download,
Link as LinkIcon,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
import { useAttachmentDownloadResolver } from "../attachment-download-context";
// ---------------------------------------------------------------------------
// Lightbox — full-screen image preview (ESC or click backdrop to close)
// ---------------------------------------------------------------------------
function ImageLightbox({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-zoom-out"
onClick={onClose}
>
<img
src={src}
alt={alt}
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}
// ---------------------------------------------------------------------------
// Image NodeView — renders img with hover toolbar + lightbox
// ---------------------------------------------------------------------------
import { Attachment } from "../attachment";
function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
const { t } = useT("editor");
const src = node.attrs.src as string;
const src = (node.attrs.src as string) || "";
const alt = (node.attrs.alt as string) || "";
const title = node.attrs.title as string | undefined;
const uploading = node.attrs.uploading as boolean;
const { openByUrl } = useAttachmentDownloadResolver();
const [lightbox, setLightbox] = useState(false);
const isEditable = editor.isEditable;
const handleView = () => setLightbox(true);
const handleDownload = () => {
// Cross-origin CDN images can't be fetched as blob (CORS),
// and <a download> is ignored for cross-origin URLs.
// Re-sign through the provider when the src maps to a known
// attachment; otherwise just open externally.
openByUrl(src);
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(src);
toast.success(t(($) => $.image.link_copied));
} catch {
toast.error(t(($) => $.image.copy_link_failed));
}
};
return (
<NodeViewWrapper className="image-node">
<figure
className={cn(
"image-figure",
selected && isEditable && "image-selected",
)}
contentEditable={false}
onClick={!isEditable && !uploading ? handleView : undefined}
>
<img
src={src}
alt={alt}
title={title || undefined}
className={cn("image-content", uploading && "image-uploading")}
draggable={false}
/>
{!uploading && (
<div
className="image-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={handleView} title={t(($) => $.image.view)}>
<Maximize2 className="size-3.5" />
</button>
<button type="button" onClick={handleDownload} title={t(($) => $.image.download)}>
<Download className="size-3.5" />
</button>
<button
type="button"
onClick={handleCopyLink}
title={t(($) => $.image.copy_link)}
>
<LinkIcon className="size-3.5" />
</button>
{isEditable && (
<button
type="button"
onClick={() => deleteNode()}
title={t(($) => $.image.delete)}
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)}
</figure>
{lightbox && (
<ImageLightbox
src={src}
alt={alt}
onClose={() => setLightbox(false)}
/>
)}
<Attachment
attachment={{
kind: "url",
url: src,
filename: alt,
uploading,
// Tiptap image node is structurally an image regardless of alt.
forceKind: "image",
}}
editable={editor.isEditable}
selected={selected}
onDelete={() => deleteNode()}
/>
</NodeViewWrapper>
);
}
export { ImageView, ImageLightbox };
export { ImageView };

View File

@@ -0,0 +1,29 @@
"use client";
/**
* Shared React Query for fetching attachment text bodies via the
* `/api/attachments/{id}/content` proxy.
*
* Same retry / staleTime / gcTime policy as AttachmentPreviewModal's local
* TextBackedPreview, lifted out so the modal and the inline `AttachmentCard`
* (file-card NodeView / readonly file-card / standalone AttachmentList) hit
* the same cache key — opening the modal after the inline preview already
* loaded a body does not refetch.
*/
import { useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
export function useAttachmentHtmlText(attachmentId: string | null | undefined) {
return useQuery({
queryKey: ["attachment-content", attachmentId ?? ""] as const,
queryFn: () => api.getAttachmentTextContent(attachmentId as string),
enabled: !!attachmentId,
// 413 / 415 won't become 200 on retry; a transport error is easier to
// recover from by re-opening than waiting on background retries with
// no UI affordance.
retry: false,
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
});
}

View File

@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
getAttachmentTextContentMock: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: { getAttachmentTextContent: getAttachmentTextContentMock },
PreviewTooLargeError: class extends Error {},
PreviewUnsupportedError: class extends Error {},
}));
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
image: { download: "Download" },
attachment: {
preview: "Preview",
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
open_in_new_tab: "Open in new tab",
},
}),
}),
}));
// Module-level flag toggled per-test to simulate desktop (openInNewTab
// present) vs web (omitted) adapters. vi.hoisted so the mock factory can
// close over it.
const { openInNewTabMock, getShareableUrlMock, navState } = vi.hoisted(() => ({
openInNewTabMock: vi.fn(),
getShareableUrlMock: vi.fn((p: string) => `https://app.example${p}`),
navState: { hasOpenInNewTab: true },
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
...(navState.hasOpenInNewTab ? { openInNewTab: openInNewTabMock } : {}),
getShareableUrl: getShareableUrlMock,
}),
}));
// Slug is required for the new-tab path to be built. The component reads
// it from useWorkspaceSlug() on @multica/core/paths — stub to return a
// fixed slug so the tests do not need a WorkspaceSlugProvider tree.
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
useWorkspacePaths: () => actual.paths.workspace("acme"),
};
});
import { HtmlAttachmentPreview } from "./html-attachment-preview";
function renderWithQuery(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => {
vi.clearAllMocks();
navState.hasOpenInNewTab = true;
});
afterEach(() => vi.restoreAllMocks());
describe("HtmlAttachmentPreview — visual shell (does not use file-card chrome)", () => {
it("does not render the filename row that AttachmentCard chrome would render", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
await waitFor(() => {
expect(document.querySelector("iframe")).toBeTruthy();
});
// The chrome row would surface the filename as text; we replace that
// entirely with an iframe + floating toolbar.
expect(screen.queryByText("report.html")).toBeNull();
});
it("renders iframe with sandbox='allow-scripts' and srcdoc when text loads", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart goes here</p>",
originalContentType: "text/html",
});
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
await waitFor(() => {
const frame = document.querySelector("iframe") as HTMLIFrameElement | null;
expect(frame).toBeTruthy();
// Critical: sandbox must not include allow-same-origin, otherwise the
// sandbox is defeated per the HTML spec.
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame?.getAttribute("srcdoc")).toBe("<p>chart goes here</p>");
});
});
});
describe("HtmlAttachmentPreview — toolbar actions", () => {
it("invokes onPreview when Maximize is clicked", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
const onPreview = vi.fn();
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={onPreview}
onDownload={() => {}}
/>,
);
await waitFor(() => expect(screen.getByTitle("Preview")).toBeTruthy());
fireEvent.mouseDown(screen.getByTitle("Preview"));
expect(onPreview).toHaveBeenCalled();
});
it("invokes onDownload when Download is clicked", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
const onDownload = vi.fn();
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
onDownload={onDownload}
/>,
);
await waitFor(() => expect(screen.getByTitle("Download")).toBeTruthy());
fireEvent.mouseDown(screen.getByTitle("Download"));
expect(onDownload).toHaveBeenCalled();
});
it("does not render a Copy code button — attachments are files, not source snippets", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
await waitFor(() => expect(document.querySelector("iframe")).toBeTruthy());
expect(screen.queryByTitle("Copy code")).toBeNull();
});
it("invokes navigation.openInNewTab with the preview path when available (desktop)", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Open in new tab")).toBeTruthy(),
);
fireEvent.mouseDown(screen.getByTitle("Open in new tab"));
expect(openInNewTabMock).toHaveBeenCalledWith(
"/acme/attachments/att-1/preview?name=report.html",
"report.html",
);
});
it("falls back to window.open against the shareable URL when openInNewTab is absent (web)", async () => {
navState.hasOpenInNewTab = false;
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
const windowOpenSpy = vi
.spyOn(window, "open")
.mockImplementation(() => null);
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Open in new tab")).toBeTruthy(),
);
fireEvent.mouseDown(screen.getByTitle("Open in new tab"));
expect(openInNewTabMock).not.toHaveBeenCalled();
expect(windowOpenSpy).toHaveBeenCalledWith(
"https://app.example/acme/attachments/att-1/preview?name=report.html",
"_blank",
"noopener,noreferrer",
);
});
});
describe("HtmlAttachmentPreview — failure mode does not unmount the toolbar", () => {
it("keeps Preview and Download enabled when fetch errors", async () => {
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("nope"));
const onPreview = vi.fn();
const onDownload = vi.fn();
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={onPreview}
onDownload={onDownload}
/>,
);
// Wait for the error placeholder — guarantees the query has settled.
await waitFor(() => {
expect(
screen.getByTestId("html-attachment-preview-error"),
).toBeTruthy();
});
// Critical: the figure does NOT collapse, and the chrome row is NOT
// rendered as a fallback. Preview and Download stay reachable.
expect(document.querySelector("iframe")).toBeNull();
expect(screen.queryByText("report.html")).toBeNull();
const previewBtn = screen.getByTitle("Preview") as HTMLButtonElement;
const downloadBtn = screen.getByTitle("Download") as HTMLButtonElement;
const openInNewTabBtn = screen.getByTitle(
"Open in new tab",
) as HTMLButtonElement;
expect(previewBtn.disabled).toBe(false);
expect(downloadBtn.disabled).toBe(false);
expect(openInNewTabBtn.disabled).toBe(false);
fireEvent.mouseDown(previewBtn);
expect(onPreview).toHaveBeenCalled();
fireEvent.mouseDown(downloadBtn);
expect(onDownload).toHaveBeenCalled();
});
});

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