Mobile can now actually create issues. Phase 1 left submit as a
console.log stub; this iteration wires Manual mode end-to-end so an
issue typed on a phone lands in the backend and appears in the user's
my-issues list on next refresh.
Wire-up:
- api.createIssue(body) — POST /api/issues, mirroring server route at
server/cmd/server/router.go:320. Matches the CreateIssueRequest type
exported from @multica/core/types so payload shape agrees across
clients.
- useCreateIssue() mutation in data/mutations/issues.ts — no optimistic
insert (the my-issues list is status-bucketed + scope-filtered, so
optimism needs bucket+scope decisions; invalidation is simpler and
hosted-backend latency is sub-300ms). onSuccess invalidates myAll
and inbox query keys.
- new-issue.tsx Manual panel: submit ↑ calls mutateAsync, dismisses on
success, surfaces errors via Alert.alert with the form state preserved
so the user can retry. Button shows a spinner during the in-flight
request and all inputs are disabled.
@ mention in description (members + agents):
- Mirrors comment-composer.tsx pattern exactly — selection tracking,
tokenAtCursor on every change/selection event, MentionSuggestionBar
rendered above the chip row, insertMention on pick, markers list
appended.
- Title input stays plain (web doesn't allow mentions in title; we
mirror that).
- Wire format on submit: serializeMentions(description, markers) →
`[@name](mention://type/id)` markdown. Recognised by:
* server/internal/util/mention.go ParseMentions
* packages/views/editor/extensions/mention-extension.ts (web Tiptap)
* apps/mobile/components/issue/mention-chip.tsx (mobile timeline)
- Backend does NOT trigger inbox notifications for mentions in issue
descriptions (only on comments — see server/internal/handler/comment.go
ParseMentions call). Mobile doesn't need to send a separate mentioned_*
field; the markdown alone is sufficient.
Header polish:
- SubmitIssueButton accepts a `loading` prop; renders ActivityIndicator
in place of the ↑ glyph while pending. Defends against double-tap.
- ModalCloseButton's earlier "Cancel" text is now a ✕ icon in a circle
to match the new-issue / search modal visual reference (Linear-style).
Agent mode unchanged — still a placeholder that console.logs and
dismisses. Phase 3 will wire the real agent picker, apiClient
.quickCreateIssue, and the daemon version gate.
Explicitly NOT in this commit (later phases):
- Markdown formatting toolbar (Phase 2C)
- Project / Labels / Due date / Parent chips (Phase 2D)
- Image / file attachments (Phase 2E)
- #MUL-42 issue references, @all mention
- Draft persistence, "Create Another" toggle
- Pre-fill from sub-issue entry, optimistic list insert
- Success toast (success path = silent dismiss; mobile has no toast
component yet)
Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
react-native-enriched-markdown does not expose JS-level custom renderers
(issues #54, #232, #246), so syntax highlighting, tap-to-lightbox, and
copy buttons cannot live inside enriched. Maintainer-endorsed workaround
(#246): split markdown at those boundaries and render the leaves in
React.
splitMarkdown walks marked.lexer tokens and emits prose / code / image
segments. Each prose island gets its own EnrichedMarkdownText; code
blocks reuse the in-house CodeBlock (Shiki + copy + horizontal scroll);
images reuse MarkdownImage (expo-image + lightbox). Paragraph-embedded
images are promoted to block siblings, matching GitHub mobile and
Linear iOS.
Drops ~600 LOC of dead walker code (render-block, render-inline, ast,
link, mention-chip, key) that the previous engine swap left behind.
Visual polish for the hybrid output:
- inline code alpha 20% → 12%; enriched paints over the full line
height and RN can't apply the padding/radius/0.85em that keep
GitHub web's chip compact, so the web alpha reads too heavy here.
- new `code-surface` token (#e8e8eb), one step darker than `secondary`,
plus a 1px `border-border` hairline. Code block now elevates inside
both white issue bodies and grey comment cards.
- code block margin my-3 — breathing room both sides.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffolds the next phase of mobile so per-feature work has a clean shell
to fill into. No new business logic, no data fetching beyond what already
existed; this is layout + navigation only.
Tab restructure (3 → 4 tabs):
- Add Chat tab placeholder (will port web bottom-right chat widget logic).
- Rename Settings → More; convert to grouped iOS-style list with sections
Workspace / Personal / Account / Workspaces, all SectionGroup + NavRow.
- Workspace switcher list inside More uses the same NavRow visual pattern
(active row marks with checkmark, inactive shows chevron).
Header (single-row):
- ScreenHeader simplified to one row: large title left, right actions
slot. Removed the second-row WS switcher idea — switcher only lives in
More now (the global header would mix scope levels with global actions).
- New HeaderActions component holds the two global actions: search and
create-issue. Wired into all 4 tabs.
My Issues filter relocation:
- Filter button moved out of the header right slot (was a scope-mismatch
hazard — global header should not host tab-local controls). Now sits
inline at the right end of the ScopeTabs row.
- New ActiveFilterChips row renders below ScopeTabs when filters are
active; each chip is tap-to-clear. Mirrors iOS Mail/Things UX.
Stubs for next phase:
- [workspace]/new-issue.tsx and [workspace]/search.tsx as modal screens
presented from HeaderActions. Both have a Cancel button (new
ModalCloseButton) in headerLeft.
- More tab sub-pages: more/{projects,agents,pins,notifications}.tsx
registered in [workspace]/_layout.tsx with native Stack headers.
Cross-cutting:
- lib/issue-status.ts exports PRIORITY_LABEL alongside STATUS_LABEL
(used by the new filter chip row).
- All new code uses Ionicons from @expo/vector-icons; not adding
lucide-react-native — see comment-composer.tsx for the reasoning.
Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change; more/ subdirectory
checked against .gitignore per CLAUDE.md mobile rule 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles the markdown rendering overhaul plus in-flight mobile feature
work as a single WIP for review.
Markdown work (the new direction):
- Swap internal Markdown component from hand-rolled marked walker to
react-native-enriched-markdown (Software Mansion, native md4c).
Public API <Markdown content={...} /> unchanged; consumers untouched.
Mention links degrade to colored links + onLinkPress routing.
- Pre-swap fixes that landed first: 3-layer inline code (later corrected),
Shiki via react-native-shiki-engine wired (now bypassed; code retained
for selective re-enable on code blocks), code block copy button with
expo-clipboard + expo-haptics, inline SVG copy/check icons, header
scale calibrated to Apple HIG, paragraph leading-6 for CJK, list
bullet column 24->16, lineBreakStrategyIOS="hangul-word" on outer
paragraph Text.
- Preprocess: <br> -> " \n" (CommonMark HardBreak) so md4c respects
intentional breaks without misreading bare \n.
- Drop the Expo Go compatibility constraint from CLAUDE.md and
markdown-renderer-research.md (project runs on dev client).
- New apps/mobile/docs/markdown-renderer-research.md captures the
RN nested-Text rendering constraints (#10775 / #45925 / #6728), the
CJK amplification mechanism, the typography scale calibration, and
every decision-log entry from the engine evolution.
Other in-flight mobile features included:
- Issue detail timeline polish, comment composer + action sheet,
mention suggestion bar, emoji picker sheet, reaction bar.
- Status / priority / assignee / label / due date picker sheets.
- My Issues filter sheet + view store.
- Realtime layer (ws-client, realtime-provider, use-inbox-realtime).
- Data layer additions (queries, mutations, schemas, attribute chips).
Cross-package:
- packages/core/api/schemas.ts: export IssueSchema for mobile use.
Build: native rebuild required after pulling (enriched-markdown is
a native Fabric module).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces `<Text>{content}</Text>` placeholders in issue description and
comment body with a full markdown pipeline at apps/mobile/lib/markdown/.
Pipeline: preprocess → marked.lexer → AST transforms → RN component tree.
Uses `marked` (~30KB JS parser) for CommonMark+GFM tokens; renderer is
hand-written (~600 LoC) for full control over RN's text-in-text rules,
mention chips, file cards, and inline-image-to-block promotion.
Supported in this drop:
- Headings, paragraphs, lists (ordered/unordered/task), block quotes,
hr, fenced code (no syntax highlight), strong/em/del/codespan, autolinks
- Mention chips: mention://member/<id>, mention://agent/<id>,
mention://issue/<id> — name resolution via existing useActorLookup;
issue tap navigates to /:slug/issue/:id
- File cards: !file[name](url) preprocessed to [📎 name](url) link;
Linking.openURL hands off to system viewers (PDF, doc, share sheet)
- Inline images promoted to block siblings (AST pass) — marked always
wraps `![]()` in paragraph and RN can't put Image inside Text
- Real aspect ratio via Image.getSize, expo-image for caching/transition,
global LightboxProvider with react-native-image-viewing for tap-to-zoom
- Tables degrade to card-per-row with header:value pairs (mobile-friendly
responsive pattern; horizontal scroll tables get lost on touch)
- Embedded HTML stripped before lexing: <br> → newline, comments removed,
other tags peeled to inner text. Residual html tokens render muted
Cross-package: lifted preprocessMentionShortcodes to @multica/core/markdown
so mobile can import it (mobile may import pure functions from core; cannot
import from packages/ui per Sharing Principles). packages/ui/markdown
keeps its own synced copy with a cross-reference comment — packages/ui
cannot import from core (Package Boundary Rules), so two synced copies
is the cleanest path.
Drops the comment-card "📎 N attachments" placeholder; markdown rendering
covers inline images and !file[] cards. attachments[] is backend cleanup
metadata, not display content (matches web).
New deps: marked@18, expo-image@55, react-native-image-viewing@0.2.
All Expo Go compatible — no native modules added.
Plan: ~/.claude/plans/plan-dynamic-narwhal.md
Research: apps/mobile/docs/markdown-renderer-research.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Activity rows previously showed a two-line `[verb] / [absolute time]` block
with no icons, mismatching web (issue-detail.tsx:1046-1100). This redesign
brings mobile in line:
- Single-line layout: [lead icon] [name] [verb...truncate] [×N] [time→]
- Contextual lead icon: StatusIcon(details.to) for status_changed,
PriorityIcon(details.to) for priority_changed, inline Calendar SVG for
due_date_changed, ActorAvatar(size=16) otherwise
- Relative time right-aligned (drops the made-up "Linear-style" absolute
timestamp; web uses relative + hover tooltip, mobile keeps relative only
for v1)
- Coalesce ×N badge for non-task actions; task_completed/failed already
bake the count into their copy
- Whole row text-xs muted-foreground — activity is supposed to feel quiet
next to comment bubbles
- FlatList contentContainer gap-3 owns row spacing; rows themselves drop
their own py so spacing doesn't double up
Calendar icon is an inline 16-line react-native-svg primitive — avoids
adding lucide-react-native to the mobile baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ApiClient hardening (data/api.ts):
- onUnauthorized callback wired in _layout.tsx — 401 clears token,
workspace store, TanStack Query cache, replaces nav to /login.
Idempotent via signingOutRef. Mirrors packages/core/api/client.ts
handleUnauthorized.
- X-Request-ID per request (lib/request-id.ts)
- Structured logger: `[api] -> METHOD path (rid)` on start, `[api] <-
STATUS path (rid, duration)` on end. console.error for 5xx,
console.warn for 404, console.log for success.
- Zod parseWithFallback for listIssues + listTimeline (the only two
endpoints with schemas in packages/core/api/schemas.ts today —
matches web's current coverage; new schemas should land on the web
side first and both clients pick them up).
Core export (packages/core/package.json):
- Add `./api/schemas` to exports map so mobile can import the shared
Zod schemas + EMPTY_* fallbacks (pure data, on the mobile sharing
whitelist per CLAUDE.md).
Issue detail v1 (app/(app)/[workspace]/issue/[id].tsx):
- Read issue + infinite-scroll timeline + comment composer
- Stack header shows MUL-XXX once detail loads
- Supporting files: data/queries/issues.ts, data/mutations/issues.ts,
components/issue/{timeline-list,comment-composer,...},
lib/{format-activity,timeline-coalesce,timeline-thread}.ts
- Property edits, reactions, mentions, image lightbox deferred to V2+
apps/mobile/CLAUDE.md — Lessons learned (encode into reflexes):
1. Install/upgrade deps: `pnpm view <pkg> dist-tags` first; `expo
install` for Expo packages, never `pnpm add` blindly
2. New source subdirectory: `git check-ignore -v` to verify against
root .gitignore generic rules (data/, build/, bin/); add !data/
override if matched. Cost a 14-file missing commit before.
3. ApiClient capability list (Zod parse / 401 callback / X-Request-ID
/ structured logger) — all baseline, not polish
4. Visual alignment is baseline, not polish — tab icons, screen titles,
right-column vertical alignment of trailing elements, type-aware
secondary lines (mirror InboxDetailLabel, not raw item.body)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical: previous commit (def9c08d) was missing apps/mobile/data/ entirely
because root .gitignore has a generic `data/` rule (for backend runtime
dirs) that swallowed mobile's source tree. Added !data/ override to
apps/mobile/.gitignore. The branch was running locally only because
untracked files still load at runtime.
Functional changes on top:
- Status icon: react-native-svg, 7 variants (backlog 16-dot ring / todo /
in_progress 0.5 / in_review 0.75 / done + check / blocked + slash /
cancelled + x). Geometry mirrors packages/views/issues/components/
status-icon.tsx (14x14 viewBox, OUTER_R=6, FILL_R=3.5)
- Priority icon: 4 ascending bars + "none" horizontal dash; mirrors web
priority-icon.tsx. Urgent pulse animation deferred.
- Inbox row click: optimistic mark-read (mirrors packages/core/inbox/
mutations.ts useMarkInboxRead) + router.push to /[ws]/issue/[id]
- My Issues row click: router.push to /[ws]/issue/[id]
- /[ws]/issue/[id] placeholder with native iOS Stack header + back
button + edge-swipe-to-dismiss
- Inbox layout: title-row right edge = StatusIcon, body-row right edge
= timeAgo, vertically aligned (matches web inbox-list-item.tsx)
- InboxDetailLabel mobile mirror at components/inbox/detail-label.tsx —
type-aware second-line ("Set status to (icon) Done" / "Mentioned" /
"Assigned to <name>" etc.). Was rendering raw markdown body which
leaked ## heading prefixes.
- Inbox dedup: deduplicateInboxItems mirrored into apps/mobile/lib/
inbox-display.ts (filter archived -> group by issue_id -> keep newest
-> sort desc). Without it mobile rendered 3 unread dots while web
sidebar showed "Inbox 1". Documented in apps/mobile/CLAUDE.md
"Behavioral parity" with the lesson: before rendering ANY list-shaped
API response, mirror every preprocessing step web/desktop runs
between useQuery and JSX (dedupe / coalesce / filter / display
helpers). Backend returns raw cache shape; client shapes it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Auth: email OTP login mirroring packages/core/auth/store.ts behavior
(401 clears token, non-401 preserves; token written only on verify
success); expo-secure-store with key "multica_token" matching desktop
- Workspace context: /[workspace]/ URL slug as source of truth (deep-
link friendly), ApiClient auto-injects X-Workspace-Slug, SecureStore
persists last-selected slug for cold-start restore
- Bottom tabs (Ionicons): Inbox / My Issues / Settings
- Inbox: actor avatar, unread brand-dot, status icon, time-ago + body
subtitle. getInboxDisplayTitle mirrored from packages/views/inbox/
components/inbox-display.ts
- My Issues: priority bars (matching IssuePriority bar counts from
packages/core/issues/config/priority.ts), status dot, identifier,
title, assignee avatar
- Settings: account info + workspace switcher; switching replaces nav
to /[newSlug]/inbox so back stack doesn't trail to old workspace
- Multi-env: .env.staging / .env.production / .env.development.local
with EXPO_PUBLIC_API_URL; APP_ENV in app.config.ts swaps
bundleIdentifier so dev/staging/prod coexist on a device
- Build: dev:mobile + dev:mobile:staging scripts; main turbo
build/typecheck/lint/test filter excludes @multica/mobile
Tech-stack (locked in apps/mobile/CLAUDE.md):
- Expo SDK 55, RN 0.83.6, React 19.2.0 (pinned, NOT catalog)
- NativeWind 4 + Tailwind 3.4 (intentional mismatch w/ web's Tailwind 4;
visual tokens transcribed by hand from packages/ui/styles/tokens.css)
- TanStack Query 5 with AppState focus listener; Zustand 5
Not in this commit (intentional): issue detail page, mark-read mutation,
pull-to-refresh polish — next iteration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Refactor root CLAUDE.md sharing rules into a single Sharing Principles
section, replacing scattered mentions across 10 places with one source
of truth + minimal "(web + desktop)" qualifiers on existing sections
- Add apps/mobile/CLAUDE.md with locked tech-stack baseline: Expo SDK 54,
React Native 0.81, NativeWind 4 + Tailwind 3.4, react-native-reusables,
TanStack Query 5, Zustand, expo-secure-store
- Mobile pins React directly (does NOT track root catalog:) so the Expo
SDK / RN release schedule isn't blocked by web/desktop upgrades
- Visual tokens are mobile-owned (transcribed from packages/ui/styles/
tokens.css by hand, not imported); Tailwind v3.4 vs v4 mismatch makes
file sharing impractical anyway
- Document mobile build/release pipeline (main CI excludes mobile,
separate mobile-verify and mobile-release workflows, EAS Update for OTA)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reserved workspace slugs lived in two parallel files (`workspace_reserved_slugs.go`
and `packages/core/paths/reserved-slugs.ts`) with no parity check. Adding or
renaming a global route on one side without the other would slip through CI
and surface only when a real user hit the collision.
Collapse the two lists into one source: `server/internal/handler/reserved_slugs.json`.
Go embeds the JSON via `//go:embed` and parses it at package init; the TS file
is regenerated by `scripts/generate-reserved-slugs.mjs` (run via
`pnpm generate:reserved-slugs`). CI re-runs the generator and `git diff
--exit-code`s the TS output, so a stale TS file cannot land. The slug set is
unchanged (87 entries, byte-equivalent slug literals).
Update CLAUDE.md to describe the new "edit JSON, run generator" workflow.
Co-authored-by: multica-agent <github@multica.ai>
Two follow-up nits from PR #2211 review:
- Rename the package-local `repoCache` interface to `repoCacheBackend`
so the field declaration `repoCache repoCacheBackend` no longer shadows
its own type name.
- Bump the `/health`-must-respond timeout in
`TestHealthHandlerRespondsWhileTaskRepoLookupWaits` from 200ms to 1s.
The regression case blocks indefinitely on the old code, so a 1s
upper bound still fail-fast detects it while leaving headroom for
loaded CI runners.
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): add disk-usage CLI to surface per-task / per-workspace footprint
Adds `multica daemon disk-usage [--by-workspace] [--by-task] [--top N]
[--output json]`, walking the workspaces root to report task and workspace
disk consumption without requiring a running daemon. Sizing reuses the GC
artifact patternSet (basename-only) so the reported "artifact" footprint
matches what `cleanTaskArtifacts` would actually reclaim, and the walk
honors the same safety contract: never enters .git, never follows symlinks,
counts only regular files.
Refactors WorkspacesRoot resolution into an exported `ResolveWorkspacesRoot`
so the read-only CLI picks the same root the running daemon would have.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): distinguish displayed totals from scan totals; add workspace artifact ratio
- Track scan-wide TotalTaskCount / TotalWorkspaceCount on the report so
`--top N` no longer leaves the table footer claiming the truncated row
count is the full count. The CLI now prints a "Showing top N of M …
Displayed: X. Scan total: Y" line whenever truncation happens, and keeps
the bare "Total: …" footer for the un-truncated case.
- Add ArtifactRatio (0..1) on WorkspaceDiskUsage and TotalArtifactRatio on
the report. The workspace table renders an `ARTIFACT %` column. ratio()
guards size=0 so empty workspaces report 0% instead of NaN%.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Filters available skills by name + description (case-insensitive) as the
user types. Auto-focuses on open and clears the query on close. Shows a
distinct "no match" empty state vs. the existing "all assigned" one.
Closes#2266
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): extend GC to chat / autopilot / quick-create tasks
Before this change the daemon's GC was strictly issue-centric: only tasks
with a non-empty issue_id ever wrote .gc_meta.json, and shouldCleanTaskDir
called only the issue gc-check endpoint. Chat / autopilot run / quick-create
tasks fell through to the GCOrphanTTL mtime path, which mis-killed active
chat sessions while leaving deleted ones around far longer than necessary.
Schema:
- GCMeta gains a Kind discriminator and per-kind ID fields
(ChatSessionID / AutopilotRunID / TaskID). WriteGCMeta now takes a
GCMeta struct so the call site classifies the task explicitly.
- ReadGCMeta defaults empty Kind to GCKindIssue, so legacy on-disk meta
files keep flowing through the issue path with no migration required.
Server endpoints (siblings of /api/daemon/issues/{id}/gc-check, all behind
requireDaemonWorkspaceAccess for the same anti-enumeration shape):
- GET /api/daemon/chat-sessions/{id}/gc-check -> {status, updated_at}
- GET /api/daemon/autopilot-runs/{id}/gc-check -> {status, completed_at}
- GET /api/daemon/tasks/{id}/gc-check -> {status, completed_at}
shouldCleanTaskDir dispatches on Kind:
- chat: active is hard-skipped (no mtime fallback) so idle sessions are
never reclaimed; archived + GCTTL cleans; 404 falls back to mtime to
stay safe for cross-workspace tokens.
- autopilot_run: terminal (completed/failed/skipped/issue_created) +
GCTTL cleans; running/pending skips. Uses run.completed_at as the TTL
anchor since autopilot_run has no updated_at column.
- quick_create: terminal task status cleans immediately (workdir is not
reused by the linked issue task, which has its own envRoot); running
skips.
Also drops the "skipping .gc_meta.json: issue_id is empty" warn — with
the new kind dispatch, chat/autopilot/quick-create tasks now write a
proper meta file instead of triggering this log.
Refs: GC follow-up to PR #2077 (symptom fix) and #2115 (chat hard delete).
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): chat gc-check 404 cleans immediately, no mtime gate
PR review caught that the chat 404 path was routing through
orphanByMTime, which deferred reclamation to GCOrphanTTL (72h) when
acceptance #3 calls for cleanup within one GC cycle (≤ 1h) after the
user hard-deletes a session.
Every chat_session_id we ever ask about was written by this same daemon
under its current token, so the cross-workspace probe defense the issue
path needs doesn't apply here. Drop the gate and clean on 404 directly.
Test updates:
- TestShouldCleanTaskDir_KindDispatch/chat_404 flips the locked
expectation from gcActionSkip to gcActionClean.
- Adds TestShouldCleanTaskDir_ChatHardDeletedFreshMtime: GCOrphanTTL
set to a year so any mtime-based path is unmistakably out, and the
fresh-mtime workdir still cleans on the chat-404 fast path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Two related changes for the same UX problem (#1857 follow-up).
1. Orphan-reply rescue. The grouping in issue-detail.tsx put replies under
their parent's CommentCard, looking them up via repliesByParent.get(parentId).
When a reply's parent wasn't in the loaded timeline — pagination boundary,
merge truncation, future backend bug — the entire reply subtree dropped
off the screen, since the orphan replies sat in the map with no
CommentCard around to render them. MUL-1847 hit this on the OLD backend:
1 root + 29 replies, the root was the oldest entry and the merge dropped
it, so all 29 replies vanished from the UI even though the API returned
them.
The fix: a reply whose parent_id points to a comment NOT in the loaded
timeline is promoted to top-level. It still loses its visual indentation
under the missing parent, but it stops disappearing.
2. Page size 50. With activities now decoupled from the comment budget
(#2253) and the off-by-one fixed (#2259), 50 fits the typical issue
without any "Show older" interaction. Cost is bounded — SQL fetches
limit+1 = 51 comments + 50 activities through the keyset index from
migration 068; response body grows ~70% over 30 but stays well under
the legacy compat path's 200-row cap. UI renders 100 entries
comfortably; CommentCards memoize.
Frontend default in `client.ts` (`limit = 50`) matches the new backend
default (`timelineDefaultLimit = 50`) so pages walk consistently.
Test: render-level case in `issue-detail.test.tsx` mocks a timeline page
containing only an orphaned reply (parent_id refers to a missing id) and
asserts the reply text appears.
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): aggregate task_usage into daily rollup table to cut DB load
ListRuntimeUsage previously did a SUM(...) GROUP BY DATE(created_at), provider,
model over the raw task_usage stream once per runtime row on the runtimes
list and once per detail page load, scaling O(events) per call. This is the
hot read path responsible for sustained load on Postgres.
Switch the read path to a materialized daily rollup table maintained by a
pg_cron job:
- 072_task_usage_daily_rollup: schema for task_usage_daily +
task_usage_rollup_state, plus rollup_task_usage_daily_window(p_from, p_to)
(window primitive used by both cron and offline backfill, idempotent via
ON CONFLICT DO UPDATE adding deltas) and rollup_task_usage_daily() (cron
entry point — pg_try_advisory_lock(4242) for serialization, watermark
advancement, 5-minute safety lag for late-visible inserts). Also adds
idx_task_usage_created_at to help the two lazy endpoints
(ListRuntimeUsageByAgent / GetRuntimeUsageByHour) that still hit the
raw table.
- 073_task_usage_daily_pgcron: CREATE EXTENSION IF NOT EXISTS pg_cron in a
DO/EXCEPTION block (mirrors the migration 032 pg_bigm pattern so envs
without shared_preload_libraries=pg_cron skip gracefully) and schedules
rollup_task_usage_daily() every 5 minutes when the extension is present.
- queries/runtime_usage.sql ListRuntimeUsage rewritten to read from
task_usage_daily; sqlc regenerated. Other usage queries unchanged.
- cmd/backfill_task_usage_daily: one-shot Go command that walks
task_usage in monthly slices through rollup_task_usage_daily_window,
then stamps the watermark to now()-5m so the cron resumes cleanly.
Run once after migrations have applied, before relying on the rollup.
- runtime_test.go: TestGetRuntimeUsage_BucketsByUsageTime now invokes
rollup_task_usage_daily_window after fixture inserts so the handler
sees the rolled-up rows. Synthetic daily rows cleaned up after each
test.
- runtime_rollup_test.go: new tests covering aggregation correctness,
idempotency contract of ON CONFLICT DO UPDATE, and the watermark
advancing exactly to now()-5m via the cron entry point.
Deployment order: apply migrations → run backfill_task_usage_daily once
→ pg_cron picks up subsequent windows automatically. Today bucket may be
up to ~10 minutes stale (5 min cron + 5 min lag) by design.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): make task_usage_daily rollup safe to overlap, replay, and correct
Addresses 4 review blockers on the original PR:
1. Cron/backfill double-count race: the rollup function is now idempotent.
Window calls find DIRTY KEYS via task_usage.updated_at, then RECOMPUTE
each bucket from ground truth and REPLACE the daily row (no more
additive ON CONFLICT). Cron and backfill can now overlap safely.
2. Silent pg_cron absence: the read path is gated behind a new
USAGE_DAILY_ROLLUP_ENABLED feature flag (default off). The raw
task_usage scan is preserved as the fallback. Operators flip the
flag per-environment after backfill + cron are confirmed healthy
(task_usage_rollup_lag_seconds() helper added for monitoring).
3. UpsertTaskUsage corrections invisible to rollup: added
task_usage.updated_at column (default now(), backfilled from
created_at), and bumped it on conflict. Corrections now mark the
bucket dirty and the next window call recomputes it correctly.
4. CREATE INDEX blocking writes on hot table: split into separate
single-statement migrations using CREATE INDEX CONCURRENTLY
(074, 075), matching the 035/067 pattern.
Also: cron.schedule() removed from migrations entirely. Migration 076
only enables the extension (gracefully on unsupported envs); the actual
schedule is a documented operator runbook step that runs AFTER backfill.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): trigger-driven invalidation + online-safe migration for task_usage_daily
Round-2 review feedback on PR #2256:
1. Add explicit dirty-bucket queue (task_usage_daily_dirty) populated by
triggers on agent_task_queue (UPDATE OF runtime_id, DELETE) and
task_usage (DELETE). The rollup window function drains both this queue
and the updated_at-based discovery, so runtime reassignment and
issue-cascade deletes no longer leave the rollup divergent from the
raw query.
Triggers join via agent (not issue) to look up workspace_id, because
when the cascade comes from issue, the issue row is already gone by
the time atq's BEFORE DELETE fires; agent stays alive.
2. Make migration 072 online-safe: only ADD COLUMN updated_at TIMESTAMPTZ
(nullable, no default → metadata-only ALTER, no row rewrite) and a
separate ALTER for SET DEFAULT now() (also metadata-only). No bulk
UPDATE on the hot task_usage table. The rollup window function's
dirty_keys CTE handles legacy NULL rows via an OR branch, supported
by partial index idx_task_usage_created_at_legacy.
3. Refresh stale documentation in cmd/backfill_task_usage_daily/main.go
header to describe the current recompute/replace semantics, idempotent
re-runnability, and the actual migration numbering (072..077).
Tests:
- TestRollupTaskUsageDaily_InvalidationOnReassign: verifies usage moves
between runtime buckets after ReassignTasksToRuntime-style update.
- TestRollupTaskUsageDaily_InvalidationOnIssueDelete: verifies daily
bucket is cleared after issue delete cascades through atq → task_usage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): close dirty-queue race + move legacy partial index to its own concurrent migration
Round-3 review feedback on PR #2256:
1. Blocker: dirty-queue invalidations could be silently lost under
concurrency. ON CONFLICT DO NOTHING let a late trigger see the row
already enqueued, no-op, and then the rollup drain (WHERE
enqueued_at < p_to) would delete the original row — losing the
late invalidation. Switched all three trigger enqueue paths to
ON CONFLICT DO UPDATE SET enqueued_at = GREATEST(existing,
EXCLUDED.enqueued_at), so any invalidation arriving during a
rollup tick keeps enqueued_at > p_to (p_to = now() - 5min) and
survives the post-tick drain.
2. High: idx_task_usage_created_at_legacy (partial index on hot
task_usage table) was being created in the regular 077 migration
without CONCURRENTLY. Moved to new migration 078 with
CREATE INDEX CONCURRENTLY, matching the pattern of 074/075.
077's down migration leaves the index alone (it is owned by 078).
3. Minor: gofmt -w on runtime_rollup_test.go and
backfill_task_usage_daily/main.go (tabs were lost in the original
heredoc append). PR description rewritten to describe the current
recompute/replace + dirty queue + feature flag design and the
072..078 migration ordering.
Tests still green: TestRollupTaskUsageDaily_* (including both new
invalidation regressions), TestGetRuntimeUsage_*, TestWorkspaceUsage_*.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(server): unify workspace_id source via agent in rollup window function
Round-4 review feedback (J) on PR #2256:
M1 (must-fix): The dirty queue triggers resolved workspace_id via
`agent.workspace_id`, but the window function's `dirty_from_updates`
discovery and `recomputed` recompute join used `issue.workspace_id`.
There is no schema-level FK guaranteeing
`agent.workspace_id == issue.workspace_id`. Any divergence (future
cross-workspace task scenarios, data repairs, migration bugs) would
cause:
- dirty queue rows with workspace_id from agent
- recompute join filtering by workspace_id from issue
- 0 matches in recompute → bucket erroneously hits the
deleted_empty branch and the daily row is silently dropped
- dirty_from_updates path attributing usage to the wrong workspace
Replaced both CTEs to JOIN agent (not issue) so trigger / discovery /
recompute share one workspace_id source. Comment in 077 explains the
constraint.
N1: Refreshed two stale references in
cmd/backfill_task_usage_daily/main.go (header now says "072..078";
stampWatermark warning now mentions migration 073, where the rollup
state table is actually introduced).
Test: New TestRollupTaskUsageDaily_WorkspaceMismatch constructs an
atq with agent.workspace_id != issue.workspace_id, asserts the bucket
lands under agent's workspace (not issue's), and re-asserts after a
runtime reassign in the foreign workspace. Acts as a canary if the
schema invariant changes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Pre-fix the gate was `len(comments) >= limit`, which fired even when the
issue had EXACTLY <limit> comments. The "Show older" affordance appeared,
the user clicked, the next page fetched zero rows. User flagged it on
MUL-1857 — "this issue happens to have 30 comments; the button shouldn't
appear in that case."
The fix is the standard over-fetch probe: ask the SQL for limit+1 rows; if
it returned more than limit, drop the extra and report hasMore=true.
Otherwise hasMore=false.
- New helper `commentOverflow(rows, limit) -> ([]db.Comment, bool)` replaces
the count-based `hasMoreCommentsBeyond`. Works for both DESC (latest /
before) and ASC (after / around-newer) since both want "keep first
<limit>".
- All four mode handlers (latest, before, after, around) now ask for
limit+1 comments and route through the helper.
- Activities still cap at <limit> with no overflow probe — they don't gate
pagination (#1857), so the boundary doesn't matter for them.
Tests:
- TestCommentOverflow pins the truth table with the boundary case
("exactly limit comments" → hasMore=false).
- TestListTimeline_ExactlyLimitCommentsHidesShowOlder is the DB-backed
regression: 30 comments, limit=30, asserts has_more_before=false and
next_cursor=nil.
Co-authored-by: multica-agent <github@multica.ai>
The pre-fix top "Show older" was a bare <button> sandwiched between two
horizontal divider lines, styled `text-xs text-muted-foreground`. Visually
it read as a divider, not an action — users on issues with hidden older
entries thought the comments had vanished and didn't notice the affordance.
Convert all three timeline pagination affordances to shadcn Button:
- Top: outline button with ChevronUp icon, "Show older"
- Bottom (in around-mode pages): outline button with ChevronDown icon,
"Show newer"; default-variant button with ArrowDownToLine icon,
"Jump to latest" (or "Jump to latest · N new")
No behavior change — same fetchOlder / fetchNewer / jumpToLatest hooks,
same i18n keys. Just the visual treatment.
Co-authored-by: multica-agent <github@multica.ai>
* fix(timeline): exclude activities from comment page budget
The /timeline endpoint paginated comments + activities through one shared
50-row budget, so an issue with a chatty agent (status flips, task_completed
markers, assignee toggles per run) could trigger "show older" with as few as
10-20 actual comments — users opened the page and thought their discussion
had vanished.
- Comment limit drops from 50 to 30 (the visible page size users wanted).
- has_more_before / has_more_after gate on comments alone via the new
hasMoreCommentsBeyond helper. Activity rows still ride along at the same
per-call SQL cap but no longer push real comments off-page.
- Merge functions stop truncating at the page limit; both pools are
individually bounded by SQL, so dropping rows here only re-introduced the
bug. The legacy (pre-cursor) path applies its 200-row cap inline.
- Test rewrite: TestHasMoreBeyond → TestHasMoreCommentsBeyond, replaced the
#2192 merge-truncation regression with a #1857 "dense activity does not
hide comments" test that pins the new contract directly.
Co-authored-by: multica-agent <github@multica.ai>
* fix(timeline): per-pool keyset cursor for comments and activities
Pre-fix, next_cursor / prev_cursor anchored on the merged page boundary
(oldest / newest entry overall). When activity rows were older than every
fetched comment — common on issues created with a status change before the
first comment — the latest page emitted a cursor pointing at that activity,
and the next "show older" call sent that timestamp into ListCommentsBefore,
skipping every unreturned comment in between. GPT-Boy flagged this on
PR #2253 with the 80-comment / 30-activity scenario where 50 comments
became permanently unreachable.
The fix splits the cursor into independent comment and activity positions:
- timelineCursor carries (CommentT, CommentID, ActivityT, ActivityID).
encode/decode signatures changed accordingly.
- New cursorPos type and four bounds helpers (commentBoundsDesc / Asc,
activityBoundsDesc / Asc) extract per-pool oldest/newest from fetched
rows, with a carry fallback so empty pools advance past the input cursor
instead of resetting.
- All four mode handlers (latest, before, after, around) now derive cursors
from each pool's own bounds. Removed the entryTimestamp / entryID helpers
that re-parsed the merged entry slice.
Tests:
- TestTimelineCursor_RoundTrip pins the encode/decode contract for the new
dual-pool format (and rejects garbage input).
- TestListTimeline_PerPoolCursorWalksAllComments reproduces GPT-Boy's exact
scenario (30 activities older than 80 comments, limit=30) and asserts
every comment is reachable through repeated `before=<cursor>` walks.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Parent and child issues already render their identifier on the issue
detail page; only the issue you're viewing is missing one. Add it to
the breadcrumb between the parent identifier (when present) and the
title, matching the existing parent identifier styling.
Refs multica-ai/multica#2243
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(daemon): use brew prefix symlink for self-restart so Linux Cellar deletion does not orphan runtimes
After brew upgrade on Linux, os.Executable() resolves /proc/self/exe to
the Cellar path (e.g. .../Cellar/multica/0.2.9/bin/multica), which
brew cleanup deletes. The previous IsBrewInstall() short-circuit skipped
EvalSymlinks to 'preserve' the symlink, but on Linux there was nothing
to preserve - the path was already resolved.
Use cli.GetBrewPrefix() to resolve the stable symlink path
<brewPrefix>/bin/multica for brew installs. Fall back to
EvalSymlinks(os.Executable()) with a warning log when GetBrewPrefix()
returns empty (brew binary missing from PATH).
Introduce package-level function vars (isBrewInstall, getBrewPrefix) so
the daemon test can override them without modifying the cli package.
Closes#1624
* fix(daemon): harden brew-prefix fallback and document the WHY
When `brew --prefix` is unavailable but the binary is under a known Cellar
root, recover the prefix from cli.MatchKnownBrewPrefix and target
<prefix>/bin/multica instead of falling back to the resolved Cellar path
(which brew cleanup just deleted).
- Extract knownBrewPrefixes + MatchKnownBrewPrefix in cli/update.go and
reuse from IsBrewInstall to keep one source of truth for the install-root
list.
- Add a WHY comment above the brew branch in triggerRestart explaining the
/proc/self/exe -> Cellar -> deleted-by-brew-cleanup chain.
- Cover both fallback paths (matched / unmatched) in daemon_test.go.
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(cli): add --content-file / --description-file for non-ASCII on Windows
Windows PowerShell 5.1 (the Win11 default) and cmd.exe re-encode HEREDOC
content through the active console codepage before piping it to a child
process. Characters the codepage cannot represent are silently replaced
with `?`, so agents on Chinese Win11 hosts emitting `--content-stdin` /
`--description-stdin` HEREDOCs land all of their Chinese as `?` in the
issue body and comments. The daemon log shows the original Chinese
correctly because slog writes to a file directly, so the regression
hides until the user opens the issue page.
Add a `--content-file <path>` / `--description-file <path>` source to
`resolveTextFlag`: the CLI reads the file straight off disk, preserves
UTF-8 bytes verbatim, and skips the shell entirely. The runtime config
injected into AGENTS.md / CLAUDE.md now surfaces this as the canonical
Windows fallback when the daemon host runs on Windows; non-Windows hosts
keep the existing stdin/HEREDOC guidance untouched.
Closes#2198, #2236.
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): route every Windows-host stdin directive at --content-file
GPT-Boy on PR #2247 caught that the previous patch only inserted a Windows
fallback into the Available Commands section. Two later prompt surfaces
still hard-coded `--content-stdin` and overrode it for the agent:
- The Codex-specific paragraph in `buildMetaSkillContent`, which always
said "always use `--content-stdin` with a HEREDOC".
- `BuildCommentReplyInstructions`, which is re-emitted on every turn for
comment-triggered tasks (both via the AGENTS.md/CLAUDE.md workflow and
the daemon's per-turn prompt) and mandated the same HEREDOC pipe.
On Windows hosts we now branch both surfaces to a file-based template:
the agent writes the body to a UTF-8 file with its file-write tool and
posts via `--content-file <path>`. Non-Windows hosts keep the existing
stdin/HEREDOC guidance untouched.
Tests:
- `TestBuildCommentReplyInstructionsWindowsUsesContentFile` pins the
Windows / non-Windows reply-instruction text directly.
- `TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin` asserts that
the end-to-end CLAUDE.md / AGENTS.md surface for a comment-triggered
Windows task has no remaining `--content-stdin` directive that could
override the Windows fallback (covers Claude + Codex providers).
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): make Windows comment block file-first, pin tests by GOOS
GPT-Boy's second review on PR #2247 flagged two follow-up blockers:
1. The Windows comment/description block in `buildMetaSkillContent` was
"stdin first, file caveat appended" — agents on Windows still saw
"Agent-authored comments should always pipe content via stdin" /
"MUST pipe via stdin" / `--description-stdin` directives before
reaching the Windows fallback, so the contradicting instruction was
live in the same prompt. Rewrite the entire Available Commands
bullet for Windows hosts as file-first: the headline line names
`--content-file`, the bulleted rules name `--content-file` /
`--description-file`, and stdin only appears in anti-prescriptive
"do NOT pipe via …" prose.
2. The existing non-Windows tests (TestBuildCommentReplyInstructions
IncludesTriggerID, TestInjectRuntimeConfigDirectsMultiLineWritesToStdin,
TestInjectRuntimeConfigCodexEmphasizesStdinForFormattedComments,
TestInjectRuntimeConfigCommentTriggerUsesHelper) all depended on
`runtimeGOOS` defaulting to non-Windows; they would silently fail on
a Windows test runner. Pin them to `runtimeGOOS = "linux"` via
save+restore and drop t.Parallel so they don't race with the
GOOS-mutating Windows tests.
Test additions:
- TestInjectRuntimeConfigWindowsRecommendsContentFile now asserts the
Windows AGENTS.md does NOT contain prescriptive stdin phrasings
(`MUST pipe via stdin`, `use --description-stdin and pipe a HEREDOC`,
`<<'COMMENT'`, `Agent-authored comments should always pipe content via
stdin`, `always use --content-stdin`) on top of the file-first
positive assertions. The ban list pins prescriptive substrings, not
bare flag names, so anti-prescriptive prose like "do NOT pipe via
--content-stdin" doesn't trip the ban.
- TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin gets the same
expanded ban list across the Available Commands, Codex paragraph,
and per-turn reply template surfaces.
- The non-Windows side of TestInjectRuntimeConfigWindowsRecommendsContentFile
pins that the Linux stdin/HEREDOC contract is still in place, so a
future refactor can't accidentally move every host to file-first.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Both `apps/desktop/build/icon.ico` (Windows installer + Multica.exe) and
`apps/desktop/build/icon.png` (Linux deb/rpm/AppImage) were the default
electron-vite scaffold "atom" placeholder. They were never updated when
the macOS `icon.icns` was switched to the Multica asterisk in #1074, and
have shipped as-is in every v0.2.x release including v0.2.26 — closes
GitHub #2195.
Source: 1024×1024 PNG extracted from the existing build/icon.icns
(icon_512x512@2x), so all three platforms now share the same artwork.
- icon.ico: BMP frames at 16/24/32/48/64/128 + PNG-compressed 256×256.
Matches electron-builder's "≥256×256" requirement and the BMP-then-PNG
format mix Windows Explorer / NSIS render best across Win10/11.
- icon.png: 1024×1024 RGBA, replacing the previous 512×512 placeholder.
No electron-builder.yml change needed — buildResources: build picks
both files up automatically.
Co-authored-by: multica-agent <github@multica.ai>
The chat window used to fire two parallel session queries (active subset
+ full list) and surfaced them through two UI entry points (the title
dropdown + a History icon panel). The two caches drifted during the
WS-invalidate window — visible as "completed → reload → ghost row"
flickers — and the History toggle was a redundant entry into the same
underlying data.
Collapse to one cache (full list, ?status=all) and one entry point
(dropdown). The dropdown groups locally into Active / Archived; the
archived group is collapsed by default with a count, and per-row
delete moves into the dropdown via hover-revealed trash + confirm
dialog. Backend stays untouched: old desktop builds still hit
GET /chat-sessions without ?status and continue receiving the active
subset, so installed clients are unaffected.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Importing a skill from a github.com URL probes the commits API to
disambiguate slash-bearing refs. On self-hosted servers the IP is often
already over GitHub's 60-req/hour unauthenticated limit, so the very
first probe returns 403 and the previous code aborted the entire
import ("validating ref \"main/skills/pptx\": github API returned
status 403").
Two changes make this resilient:
* Forward GITHUB_TOKEN as a bearer token on every api.github.com request
via a new doGitHubAPIGet / addGitHubAuthHeader helper. With a token,
the limit becomes 5000 req/hour and the issue disappears entirely.
* When the API still returns 401/403/429 (no token, or limit exhausted
on the higher tier) treat the probe as indeterminate via
errGitHubAPIBlocked, keep trying remaining candidates, and finally
fall back to parseGitHubURL's optimistic single-segment split. This
covers the common case (single-word refs like "main") even when the
API is fully blocked. A warn log points operators at GITHUB_TOKEN.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* docs(claude): add API Response Compatibility section
Narrows the existing "no backwards compat" rule to internal code only,
and adds a new section that codifies the defensive boundary at API
edges: parse-don't-cast, never pin UI to a single field, enum drift
must downgrade not crash.
Driven by #2143/#2147/#2192 — all three were the desktop client white-
screening on backend response shape changes the client wasn't built
against.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(core): add zod-based API response validation layer
Introduces a defensive boundary so a malformed backend response
degrades into a safe fallback (empty page, [], etc.) instead of
throwing inside React render.
- Adds zod to the pnpm catalog and as a @multica/core dependency.
- New parseWithFallback helper in core/api/schema.ts that runs
safeParse, logs a warn with the endpoint + zod issues on failure,
and returns the caller-supplied fallback. Never throws.
- Schemas in core/api/schemas.ts are deliberately lenient (string
enums kept as z.string() so unknown values still parse, optional
fields default, nested records use .loose() for unknown keys).
- Wires setSchemaLogger from CoreProvider so warnings flow through
the same logger as the rest of the API client.
This is the primitive — see the next commit for the call-site wiring.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(api): guard top 5 high-risk endpoints with parseWithFallback
Wraps the response of the five endpoints whose UIs white-screened in
past incidents (#2143/#2147/#2192) so a contract drift returns a safe
fallback instead of crashing the consumer:
- listIssues → ListIssuesResponseSchema, fallback { issues: [], total: 0 }
- listTimeline → TimelinePageSchema, fallback empty page
- listComments → CommentsListSchema, fallback []
- listIssueSubscribers → SubscribersListSchema, fallback []
- listChildIssues → ChildIssuesResponseSchema, fallback { issues: [] }
getIssue is intentionally NOT wrapped: there is no sensible "empty
issue" — the entire detail page depends on real fields. The page-level
ErrorBoundary (separate commit) catches that case.
Adds schema.test.ts with 9 cases covering the five failure modes
listed in MUL-1828: missing fields, wrong types, enum drift, null
body, and null arrays.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(ui): add ErrorBoundary and wrap high-risk pages
Section-level error boundary (no third-party dep — class component +
default fallback in @multica/ui). Supports a fallback render prop and
resetKeys for auto-recovery on resource navigation.
Wraps the surfaces that white-screened in past incidents:
- IssueDetail (web + desktop + inbox split-pane) — keyed on issueId
so navigating to a different issue clears the boundary automatically.
- IssuesPage (web + desktop).
Boundaries are placed at consumer call sites rather than inside
IssueDetail itself so we don't have to refactor the 1100-line
component, and so a crash inside one inbox split-pane doesn't take
down the inbox list next to it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(core): make all API schemas .loose() to preserve unknown fields
zod 4 z.object() defaults to STRIP, which silently drops fields the
schema didn't list. That makes the schema layer a sync point: a future
PR adding a TS field but forgetting the schema would have the field
disappear at runtime while TS still claims it exists — the exact bug-
class this PR is meant to prevent, just inverted.
Apply .loose() to every object schema (TimelineEntry, TimelinePage,
Comment, Issue, ListIssuesResponse, Subscriber, ChildIssuesResponse)
so unknown server-side fields pass through unchanged. Add a regression
test that feeds a payload with extra fields at both entry and page
level, and a direct unit test for parseWithFallback decoupled from any
endpoint. Update the listIssues fallback test to use a wrong-type
payload — under .loose() the previous "{ unexpected: true }" payload
parses successfully (every declared field has a default) instead of
triggering the fallback path it was meant to exercise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(claude): strip field-specific examples from API Compatibility section
The original wording embedded current schema field names (entries,
has_more_before, has_more_after, cursor, status, type) directly in the
rules. CLAUDE.md should state the rule, not the implementation — once a
field is renamed the doc drifts out of sync with the code, and the
specific names don't add anything the abstract rule doesn't.
Keep the rule, drop the field-level archaeology.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): guard IME composition on Enter-to-submit handlers
Chinese/Japanese/Korean IMEs use Enter to commit a multi-key
composition. When that Enter also triggers a submit/create handler,
the form fires before the user has finished typing.
Add a shared `isImeComposing` predicate in @multica/core/utils that
checks both `nativeEvent.isComposing` and `keyCode === 229` (Safari
clears isComposing on the commit keydown but keyCode stays 229).
Apply the guard to every Enter→action handler in packages/views where
the input can hold IME text: workspace name, agent name/description,
skill name, label name/edit, mention suggestion picker, property
picker search, delete-workspace typed confirmation.
Tiptap submit-shortcut already guards via `view.composing`; left as is.
Skipped numeric/email/URL/file-path inputs where IME does not apply.
Co-authored-by: multica-agent <github@multica.ai>
* style(agents): align Escape handling with early return in inspector
Three onKeyDown handlers in agent-detail-inspector.tsx now follow the same
shape as labels-panel: handle Escape with an explicit return, then the IME
guard, then Enter submit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(timeline): include merge-truncation case in has_more_before (#2192)
Older comments became unreachable on issues where activity-log entries
crowded them out of the latest 50-entry page. The 'show earlier' button
was hidden and no cursor was emitted because the has_more_before formula
only caught the per-table SQL cap case and missed the in-memory merge
truncation case.
Reproduces with 48 comments + 49 activities, default limit 50: neither
table individually returns >= limit rows, but their sum (97) exceeds the
merged page size, so the merge silently drops 47 older comments. The old
formula reported has_more_before=false; the client never asked for page 2.
Fix: extract hasMoreBeyond(c, a, e, limit) with the missing third
disjunct - comments + activities > entries - applied uniformly to
listTimelineLatest / Before / After / Around.
Backwards compatible: API contract unchanged. Pre-cursor clients
(<=v0.2.25) still hit listTimelineLegacy and never read these fields.
Newer clients see has_more_before flip from 'wrongly false' to correctly
true/false - no field renames, no shape changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(issues): show count badge when activities are coalesced (#2192)
The timeline coalesces consecutive same-actor + same-action activities
within a 2-minute window so 48 status_changed entries don't take 48 rows.
The count badge was only rendered for task_completed / task_failed; for
status_changed (and every other action) the coalesced batch silently
collapsed to a single line with no hint that N entries were merged.
Add a coalesced_badge translation and render '×N' next to the activity
text whenever coalesced_count > 1, suppressing it on task_completed /
task_failed which already include the count in their translation copy.
This pairs with the backend fix for #2192: once the older-comments page
becomes reachable again, the activity rows above it should make the
density of the merged batch visible rather than misleading the user
into thinking only one event happened.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issues): add Copy local workdir path to issue menu
Surface the daemon-pinned task work_dir on the AgentTaskResponse and add a
"Copy local workdir path" action to the issue dropdown / context menu. The
action picks the most recent task with a recorded work_dir and writes it
to the clipboard so users can jump straight to the local execution
directory to inspect results.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): preserve user activation in Copy local workdir path
Move the task list subscription out of useIssueActions and into
IssueActionsMenuItems, where Base UI lazily mounts the menu content
only after the user opens the menu. The click handler now reads
straight from the cached query result and writes to the clipboard
synchronously, so the awaited fetch no longer drops the browser's
transient user activation when the cache is cold (e.g. opening the
context menu on an issue list row that hasn't pre-populated the
ExecutionLogSection cache).
Per Emacs PR review.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add `multica workspace update` to edit workspace metadata
Closes the CLI-side gap for #2178: the `PATCH /api/workspaces/{id}`
endpoint and TS client method already exist, only the CLI subcommand
was missing. Supports partial updates of name, description, context,
and issue_prefix; long fields accept stdin via `--description-stdin` /
`--context-stdin`. `slug` stays immutable, `settings`/`repos` are out
of scope (deferred). Empty PATCH is rejected locally so we don't fire
a no-op `EventWorkspaceUpdated` broadcast. Permission gate is
unchanged (server-side admin/owner middleware).
Co-authored-by: multica-agent <github@multica.ai>
* fix(cli): address review on workspace update command
- Reject `--issue-prefix ""` (and whitespace-only) explicitly. The
server handler silently skips empty prefixes, so the previous
behavior was a 200 OK with no actual change — exactly the kind of
invisible no-op Emacs flagged in review.
- Restore the `## Issues` H2 in the zh CLI reference. The earlier
edit dropped it, leaving issue commands nested under the Workspaces
section.
Co-authored-by: multica-agent <github@multica.ai>
* docs(cli): list `workspace update` in the en + zh top-level reference
Mirrors the existing zh-only entry under apps/docs/content/docs/cli/
into the English overview so the new command is discoverable from
both locales.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Archiving the currently selected inbox item used to clear the selection
and leave the detail panel empty, forcing the user to click the next
item to keep going. Pick the next (older) item from the deduplicated
list, falling back to the previous (newer) one when archiving at the
bottom, and only clear when nothing is left.
Route the detail panel's onDone path through the same handleArchive so
the auto-select behavior is shared.
Co-authored-by: multica-agent <github@multica.ai>
PR #2101 swapped the openclaw runtime adapter from reading --json on
stderr to stdout. That fixed openclaw 2026.5+ but inverted the breakage
for pre-2026.5 builds — those still write JSON to stderr, so the
adapter now sees an empty stdout and falls through to the same
"openclaw returned no parseable output" failure that 2026.5+ users
saw before #2101.
Add a per-task version gate inside openclawBackend.Execute that runs
`openclaw --version`, parses the dotted version, and rejects anything
below 2026.5.5 with a hardcoded upgrade hint:
openclaw <detected> is below the minimum supported version 2026.5.5.
Run `openclaw update` to upgrade and try again.
The check is intentionally per-task and uncached so users who upgrade
do not need to restart the daemon — the next task automatically
re-checks. ~20ms per task is negligible vs. the typical run.
Co-authored-by: multica-agent <github@multica.ai>
Multica's openclaw runtime adapter has been reading agent output from
stderr since the early openclaw integration days. Current openclaw
(2026.5.5, c37871e) writes its --json blob exclusively to stdout:
$ openclaw agent --local --json --agent main --message 'say hi' >stdout 2>stderr
STDOUT bytes: 27401
STDERR bytes: 0
Result: every successful turn was followed by a daemon-generated system
comment 'openclaw returned no parseable output', visible to users,
looked like the agent broke when it didn't. Reproduced live on WOR-2,
turn at 2026-05-05 16:35 UTC; daemon log confirmed the full result JSON
arrived on the [openclaw:stdout] debug channel and was discarded while
the empty stderr pipe hit the no-events fallback.
Changes
- server/pkg/agent/openclaw.go: swap pipes, StdoutPipe() for the JSON
stream, cmd.Stderr = newLogWriter(...) for log overflow. Cleanup
goroutine now closes stdout on cancel. Comments and the read-error
errMsg updated to reflect the new pipe.
- server/pkg/agent/openclaw_test.go: TestOpenclawProcessOutputReadError
asserts on 'read stdout' (was 'read stderr'), string-only fix,
no behavior change. New TestOpenclawProcessOutputStdoutFixture feeds
a recorded openclaw 2026.5.5 --json blob through processOutput and
asserts result + messages parse cleanly.
- server/pkg/agent/testdata/openclaw-2026.5.5-stdout.json: 27401-byte
fixture captured fresh from the openclaw CLI for the regression test.
Side effects (net positive)
- Log lines openclaw writes to stderr (security warnings, tool errors)
now show up under [openclaw:stderr] instead of being silently consumed
by the JSON parser.
- Daemon's success_pattern heuristic (empty-output -> 'blocked')
becomes meaningful again because result.Output actually populates.
Closes WOR-10.
* fix(skills): drop SKILL.md content from list endpoints (#2174)
`GET /api/skills` and `GET /api/agents/{id}/skills` were SELECT *'ing the
skill row and shipping the full SKILL.md `content` blob to every caller.
SKILL.md bodies routinely run 50–200KB each, so a workspace with 30–40
skills returned multi-megabyte JSON arrays — past the CLI's 15s timeout
on high-latency links and locking out non-US users entirely.
Add `ListSkillSummariesByWorkspace` / `ListAgentSkillSummaries` sqlc
queries that omit `content`, plus a dedicated `SkillSummaryResponse`
wire shape so the contract is explicit (versus stuffing
`Content: ""` back into the existing struct). Detail endpoints
(`GET /api/skills/{id}`, agent CRUD return values) keep returning the
full body.
`AgentResponse.skills` and the matching TS `Agent.skills` now use
`SkillSummary[]` — frontend list/columns code already only read
id/name/description/config.origin, so the type narrowing matches actual
usage and prevents new code from accidentally depending on a content
field that won't be there.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): narrow embedded skills to AgentSkillSummary; gofmt agent.go
GPT-Boy review of #2180: the previous commit typed AgentResponse.Skills as
[]SkillSummaryResponse, but the agent list batch query
(ListAgentSkillsByWorkspace) only joins agent_id/id/name/description, so
the wider type left workspace_id/config/created_at/updated_at as zero
values. Define a dedicated AgentSkillSummary {id,name,description} that
matches what the batch query actually returns and what the frontend
actually reads (`agent.skills.map(s => s.name|s.id)`); the standalone
GET /api/agents/{id}/skills endpoint keeps SkillSummaryResponse for
callers that need the source/origin info.
Switch GetAgent's per-agent skills load from ListAgentSkills (full Skill
rows including content) back to ListAgentSkillSummaries to avoid reading
SKILL.md bodies just to discard them.
Re-run gofmt on agent.go to fix the field-tag alignment that drifted when
Skills changed type.
Co-authored-by: multica-agent <github@multica.ai>
* docs(types): correct SkillSummary JSDoc — Agent.skills is AgentSkillSummary[]
GPT-Boy spotted on review: comment said SkillSummary was "embedded in
Agent.skills", but that field is now AgentSkillSummary[]. Re-point the
reader at the right type to avoid future confusion.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Markdown links like `[xx](/workspaces)` written in `*.zh.mdx` rendered
as bare `<a href="/workspaces">`, which Next's basePath rewrote to
`/docs/workspaces` and the docs middleware then routed to English —
silently kicking Chinese readers out of their locale on every internal
click.
Add a `LocaleLink` MDX `a` override that runs every internal href
through `prefixLocale(href, lang)` before passing it to `next/link`, and
wire a `DocsLocaleProvider` around the MDX body in both page entry
points so the override and `NumberedCard` know the active locale.
External links, in-page anchors, relative paths, already-prefixed
paths, and default-language pages are deliberately left untouched.
Closes the bug reported in https://github.com/multica-ai/multica/issues/2173.
Co-authored-by: multica-agent <github@multica.ai>
* feat(create-issue): add border beam to "switch to agent" button
Draws the eye to the manual→agent affordance so users discover quick
capture mode. Adds a reusable .border-beam utility (conic-gradient ring
on ::before, driven by an @property-animated angle) and applies it to
the switch-to-agent button alongside a brand-tinted background tint and
a hover icon flip. Honors prefers-reduced-motion.
Co-authored-by: multica-agent <github@multica.ai>
* style(border-beam): switch to magic-ui colorful palette
Replaces the single brand-color sweep with a rainbow trail
(#ffbe7b → #ff777f → #ff8ab4 → #a07cfe → #5b9dff), matching the
`colorVariant="colorful"` look from magic-ui's border-beam reference.
Static fallback under prefers-reduced-motion uses the same palette as a
linear gradient.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The "+" button in each status column/section opens the create-issue
modal. On the project detail page it was passing only `{ status }`,
so the new issue's project field came up empty even though the user
was clearly in a project context. Thread `projectId` through
BoardView/ListView down to BoardColumn/StatusAccordionItem and
include `project_id` in the modal payload when set.
Co-authored-by: multica-agent <github@multica.ai>
#2128 changed GET /api/issues/:id/timeline from a bare TimelineEntry[] to
a wrapped { entries, next_cursor, ... } object. Multica.app ≤ v0.2.25 still
in the wild reads the response body as TimelineEntry[] directly, so the
moment v0.2.26 backend rolled out, every old desktop hit
"timeline.filter is not a function" on any issue open — bug reports landed
within ten minutes of the v0.2.26 release (#2143, #2147).
The new client always sends ?limit=..., so absence of every pagination
param uniquely identifies a legacy caller. Detect that at the top of
ListTimeline and serve the old shape (ASC, []TimelineEntry, capped at 200)
through a dedicated listTimelineLegacy helper. New clients fall through
unchanged.
A new TestListTimeline_LegacyShapeForPreCursorClients pins the contract
(array shape, ASC order, "[]" not "null" on empty issues). Two existing
tests that used the empty query string have been updated to send
?limit=50, since the empty form is now reserved for the compat path.
The legacy branch can be deleted once desktop auto-update has rolled the
user base past v0.2.26.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>