Closes the regression reported in https://github.com/multica-ai/multica/issues/2515 that
PR #2437 only half-fixed in v0.2.31.
Two gaps remained on Ubuntu/GNOME:
1. The .deb shipped only the source 1024×1024 PNG under
/usr/share/icons/hicolor/, with no usable smaller sizes. GNOME's hicolor
lookup walks 16…512 and falls back to the theme default when none
match, so the launcher had no icon. The auto-generation pass in
electron-builder silently produced only the source size for us. Drop
pre-rendered 16/24/32/48/64/128/256/512 PNGs into build/icons/ and
point `linux.icon` at the directory so packaging stops depending on
the toolchain re-running that generation correctly.
2. WM_CLASS at runtime was `@multica/desktop`, while the .desktop file
declared `StartupWMClass=Multica`. PR #2437 assumed Electron derives
WM_CLASS from electron-builder.yml's `productName`, but Electron
reads `app.getName()`, which reads the *packaged ASAR's* package.json
— productName if present, otherwise name. Our source
apps/desktop/package.json had no top-level productName, so the ASAR
carried only `name: "@multica/desktop"` and Chromium emitted that as
WM_CLASS, breaking the .desktop association and the dock icon.
Fixed in two anchors for belt-and-braces: add
`"productName": "Multica"` to apps/desktop/package.json (so the ASAR
carries it and app.getName() resolves correctly by default), and call
`app.setName("Multica")` in the production branch alongside the
existing dev-only setName so a future regression in package.json or
the build pipeline cannot silently re-break WM_CLASS.
The `StartupWMClass: Multica` declaration in electron-builder.yml stays
pinned and the surrounding comment has been rewritten to record the
correct WM_CLASS derivation.
Verification on a real Ubuntu install:
- `dpkg-deb -c multica-desktop-*-linux-amd64.deb | grep hicolor` lists
≥8 sizes.
- `xprop WM_CLASS` on the running window prints `"multica", "Multica"`.
- Launcher and dock both show the Multica logo with no manual
~/.local/share/icons workaround.
Co-authored-by: multica-agent <github@multica.ai>
Base UI's Menu uses focus-follows-cursor — hovering a sibling row drags
DOM focus to that row, which made the rename input's onBlur=save fire
just from moving the mouse. The result: clicking the pencil and then
nudging the cursor would silently commit a half-typed title.
Replace the blur handler with a document-level pointerdown listener
(capture phase, so it runs before Base UI's outside-click close handler
unmounts the input). The listener only commits when the user actually
clicks somewhere outside the input. Enter still commits, Escape still
cancels, mouse hover is now a no-op.
MUL-2110
Co-authored-by: multica-agent <github@multica.ai>
Gemini CLI's folder-trust feature throws FatalUntrustedWorkspaceError
(exit code 55) when the current workspace isn't in
`~/.gemini/trustedFolders.json` and the process is headless — no
interactive trust prompt is available. The daemon spawns gemini with
`-p` + `--yolo` in a freshly checked-out worktree that the user has
never trusted interactively, so every run with `security.folderTrust`
enabled fails after ~10s with exit status 55 and no useful output.
Default `GEMINI_CLI_TRUST_WORKSPACE=true` on the child env to short-
circuit `checkPathTrust` in gemini-core. This mirrors gemini-cli's
documented `--skip-trust` flag; the env var has been gemini's
documented headless escape hatch for the entire folder-trust feature
lifetime so the fix works on every gemini version that can produce
the crash. Callers that explicitly set the same key in cfg.Env win,
preserving the ability to opt back into the gate.
Co-authored-by: multica-agent <github@multica.ai>
The gemini CLI's Windows shim emits `Active code page: 65001` (from
`chcp`) to stdout before the real version reaches `--version` output.
The daemon stored the raw concatenation as the runtime version, so the
runtime detail page rendered `Active code page: 65001 0.42.0` instead
of `0.42.0`.
Scan `<cli> --version` line by line and return the first line carrying
a semver-shaped token. Full strings like `2.1.5 (Claude Code)` or
`codex-cli 0.118.0` survive unchanged; unparseable output falls back to
the trimmed raw value.
Co-authored-by: multica-agent <github@multica.ai>
Adds a pencil icon next to the trash icon on each session row in the chat
dropdown. Clicking it turns the title into an inline editable input:
Enter / blur saves, Escape cancels.
Server: new PATCH /api/chat/sessions/{id} handler that updates the title
via the existing `UpdateChatSessionTitle` sqlc query, broadcasts a new
`chat:session_updated` WS event so other tabs / devices stay in sync, and
rejects blank titles. Frontend mutation is optimistic with rollback,
matching the existing delete-session pattern.
MUL-2110
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): seed user-installed Codex skills into per-task CODEX_HOME
Codex is the only daemon runtime whose HOME is redirected — the daemon
sets CODEX_HOME to a per-task isolated directory so each task gets a
clean config slate without polluting ~/.codex/. Side effect: the codex
CLI never sees the user's `~/.codex/skills/` and tells the user no skill
was found.
Other runtimes (claude / copilot / opencode / pi / cursor / kimi / kiro)
don't have this issue: they leave HOME untouched and discover both
user-level skills (from ~/.<runtime>/skills) and workspace-assigned
skills (written to a workdir-local dotfile dir) natively. Codex is the
outlier.
Fix: in execenv.Prepare and execenv.Reuse, copy each subdirectory under
`~/.codex/skills/` into the per-task `codex-home/skills/` before writing
workspace-assigned skills. Workspace skills still win on sanitized-name
conflict; user-level installer symlinks (lark-cli style) are followed so
the per-task home gets real content rather than dangling links.
Closes#1922
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): wipe per-task codex skills dir before each hydration
Without this, the Reuse path leaves two classes of stale state behind:
1. Round 1 seeded user skill `writing/drafts/stale.md`. Round 2 reuses
the same workdir with workspace skill `Writing` assigned: seed
stage skips user `writing` (reserved), workspace stage writes
`SKILL.md` via MkdirAll + WriteFile but never clears the directory,
so the round-1 user support files surface under the workspace
skill — violating "workspace fully wins on name conflict" and
potentially leaking user-level files into a workspace skill view.
2. User uninstalls a skill from ~/.codex/skills between two runs. The
prior copy in codex-home/skills/<name>/ lingers, so the codex CLI
keeps seeing the removed skill.
Fix: RemoveAll(codex-home/skills) at the start of hydrateCodexSkills,
then re-seed user skills and re-write workspace skills. On Prepare
this is a no-op (envRoot was already wiped); on Reuse it resets the
slate.
Added two regression tests covering both scenarios.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
In a new chat (no active session), the first send momentarily rendered
ChatMessageSkeleton before the user's message appeared. Root cause:
ensureSession called setActiveSession(newId) immediately after creating
the session, *before* handleSend wrote the optimistic message to the
chatKeys.messages(sessionId) cache. useQuery's first subscription to the
new key saw no data → isLoading=true → showSkeleton rendered for one
frame.
Apply TanStack Query's "seed the cache before subscription" pattern:
move setActiveSession out of ensureSession and into the callers, after
they've primed the messages cache. handleSend writes the optimistic
user message first, then flips activeSessionId; handleUploadFile seeds
an empty array first, then flips. useQuery's first read hits cache
synchronously and ChatMessageList mounts directly — no Skeleton frame.
This is a distinct race from the chat-done flicker fixed in #2509
(unmount/mount on reply completion); both share the same prime-before-
subscribe shape.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): collapse chat-done flicker via inline cache write
The chat panel flickered at end-of-turn: live TimelineView unmounted →
short blank + scroll jump → persistent AssistantMessage finally appeared.
Root cause: chat:done's WS handler called setQueryData(pendingTask, {})
synchronously while invalidateQueries(messages) was an async refetch.
The render guard pendingAlreadyPersisted (chat-message-list.tsx:62-68)
expected the persisted message to already be in the messages cache
before pending cleared, but the sync/async ordering broke that guard.
Fix follows TkDodo's "combine setQueryData (active query) + invalidate
(others)" pattern. ChatDonePayload now carries the freshly-persisted
ChatMessage (id, content, elapsed_ms, created_at); the WS handler
writes it into chatKeys.messages BEFORE clearing pending. Same render
tick → AssistantMessage mounts before TimelineView unmounts → no
flicker. invalidate(messages) stays as a fallback for clients that
took the older code path or for content drift (redaction, etc.).
Also slim task:completed's chat branch — chat:done already wrote the
message and cleared pending; task:completed only refreshes the
cross-session pending aggregate that drives the FAB.
Field additions are all `omitempty` / TS `?:` so older clients ignore
them and older servers (no fields populated) fall back to invalidate-
only, preserving prior behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(chat): cover chat done cache handoff
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>
Co-authored-by: Eve <eve@multica-ai.local>
`disableMentions` previously skipped registering BaseMentionExtension entirely,
which removed the `mention` node type from the editor's schema. Pasting any
ProseMirror slice from another Multica editor (clipboard `text/html` carries
`data-pm-slice`) caused ProseMirror to silently drop the mention nodes and any
surrounding inline text glued to them.
Keep the extension registered in all cases. When `disableMentions=true`, attach
an inert suggestion (`allow: () => false`) so typing `@` still does not pop the
picker — matching the original product intent for agent system prompts — but
existing mentions pasted in survive and render as the normal pill.
Earlier attempt #2477 patched the paste classifier instead and broke in a
different way (`mention://` href tripped the markdown link validator),
which led to revert #2510.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(desktop): route attachment downloads through Electron native system on Linux
Replaces shell.openExternal with webContents.downloadURL for attachment
downloads in the Electron desktop app. On Linux/Ubuntu, opening a
CloudFront URL serving Content-Type: text/html via the system browser
causes the browser to render the HTML inline instead of downloading.
Electron's native downloadURL shows a save dialog and saves the file
directly, fixing HTML downloads regardless of Content-Type.
* test(views): update desktop download test to match the new downloadURL bridge
The test still referenced the old openExternal bridge. Updated it to
assert desktopAPI.downloadURL() instead.
* fix(desktop): add URL scheme allowlist to download IPC handler
Addresses review feedback on PR #2441.
The file:download-url IPC handler called webContents.downloadURL
directly, bypassing the http/https allowlist enforced by
openExternalSafely. Adds downloadURLSafely() alongside the existing
openExternalSafely wrapper, reuses the same isSafeExternalHttpUrl
check, and extends the ESLint no-restricted-syntax rule to ban direct
webContents.downloadURL calls.
Also handles nits: observable warning on null mainWindow, removes dead
openExternal field from DesktopBridge, adds desktop-branch failure test.
The page added in #2462 lived at `/{slug}/dashboard` and was titled
"Dashboard", which collides with the conventional meaning ("personal
landing surface") and doesn't tell new users what the page is for. Its
actual contents — token spend, cost, run time, task counts — map cleanly
onto the OpenAI / Anthropic / Vercel "Usage" surface, so rename to that.
Renames (user-visible)
- Route: `/{slug}/dashboard` → `/{slug}/usage` (web App Router + desktop
memory router)
- Sidebar entry: label "Dashboard" / "看板" → "Usage" / "用量", icon
LayoutDashboard → BarChart3 (page header icon swapped in sync)
- Page title in en/zh-Hans
- Reserved-slugs: add `usage` to workspace route segments group;
`dashboard` stays reserved in the marketing group (back-compat against
workspace slug collisions + keeps the name free for a future Home page)
- i18n namespace `dashboard` → `usage` across resources-types.ts,
locales/index.ts, and the moved JSON files
- WORKSPACE_ROUTE_SEGMENTS in editor link-handler
- paths.workspace(slug).dashboard() → .usage(), with matching test
expectation updates
Per-agent leaderboard polish (`packages/views/dashboard/components/
dashboard-page.tsx`)
- Card title "Cost & run time by agent" → "Leaderboard" with a 4-way
Segmented control: Tokens / Cost / Time / Tasks
- Active metric drives row order, progress-bar width, and the
emphasised column header / cell — keeping ranking, visual quantity,
and column emphasis in lockstep so users always see what's being
measured
- Default sort = Tokens (most universally meaningful; Cost still one
click away)
- Project filter dropdown:
- Show ProjectIcon next to the selected project + each list item;
FolderKanban as the "All projects" fallback (matches ProjectPicker
language)
- alignItemWithTrigger={false} so "All projects" doesn't get pushed
above the trigger and clipped when the header sits at the top of
the viewport (was the root cause of "can't re-select All projects"
once a project was selected)
- max-h-72 to cap the dropdown when workspaces accrue many projects;
matches the runtime-detail Select precedent
- Folder name `packages/views/dashboard/*` and `DashboardPage`
component name intentionally left in place — user-visible rename
only, no broad code refactor.
Old `/dashboard` routes are not redirected because the page only landed
in #2462 (a few days ago); no real users, external links, or
desktop-tab persistence have settled on it yet.
The editor underneath the feedback textarea already supports image/file
upload via paste and drag-drop, but the modal has no visible affordance
— users had no way to discover this. Chat input has the same plumbing
and exposes it through a paperclip button; mirror the pattern here.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Shiki's default bundle doesn't include the `env` grammar, so MDX
prerendering fails with `Language `env` is not included in this
bundle.` The two pages added in #2474 used ```env, which broke both
Preview and Production deployments of multica-docs.
Swap the language tag to `dotenv` (Shiki ships it by default) — same
visual result, no Shiki config change needed.
Refs MUL-2122
Co-authored-by: multica-agent <github@multica.ai>
When an agent completes successfully (exit 0) but produces no text
output, the daemon incorrectly classified it as 'blocked'. This is
wrong — agents can legitimately complete work via tool calls (posting
comments, pushing code) without emitting text output.
Change the empty-output path to return status=completed so the task
is correctly reported as successful.
Fixes MUL-2104
Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(dashboard): workspace/project token + run-time dashboard
Add a `/{slug}/dashboard` page showing per-agent token spend and execution
time across the whole workspace, with an optional project filter.
Backend:
- Three new sqlc queries against task_usage + agent_task_queue: daily
usage, per-agent usage, per-agent total run-time. All optionally
scoped to a project via sqlc.narg('project_id'), reaching project
through the issue join.
- Handlers under /api/dashboard return the same wire shape the runtime
page already consumes (model preserved for client-side cost math).
Frontend: - Shared DashboardPage in packages/views/dashboard reusing KpiCard,
DailyCostChart, ActorAvatar, and estimateCost from the runtime page
so the visual style and pricing math stay in lock-step.
- Period selector (7/30/90d), project dropdown, four KPI tiles
(cost, tokens, run time, tasks), daily cost chart, and a combined
"cost + run time by agent" list.
- Routed in both web (app/[slug]/(dashboard)/dashboard) and desktop
(memory router); sidebar nav entry added under Workspace group.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard): drop stale project filter and stop double-counting tasks
Two issues caught in PR #2462 review:
1. Project filter held the previous selection's UUID across workspace
switches and project deletions: the dropdown gracefully showed
"All projects" (because the title lookup missed) while the three
dashboard queries kept forwarding the dead UUID, leaving the UI
looking like a full-workspace view but populated with empty
project-scoped data. Validate the picked UUID against the current
projects list before passing it to the queries.
2. The "by agent" table read its task count from the token rollup,
which is grouped per (agent, model). A single task that spans two
models lands twice and the agent's row reads e.g. "2 tasks" when
the real count is 1. Prefer `ListDashboardAgentRunTime`'s per-agent
distinct count when available; fall back to the token aggregate
only for agents with no terminal run yet (in-flight tasks).
Extract the merge into `mergeAgentDashboardRows` so the precedence
rules are unit-tested directly.
Co-authored-by: multica-agent <github@multica.ai>
* test(dashboard): allocate per-workspace issue.number explicitly
TestDashboardEndpoints creates two issues in the shared fixture
workspace. issue.number defaults to 0 (migration 020), and the table
carries UNIQUE (workspace_id, number), so the second insert raced the
first on the same default and failed in CI.
Allocate MAX(number) + 1 per insert so each row gets a fresh number
without stepping on rows other tests left behind in the same workspace.
Co-authored-by: multica-agent <github@multica.ai>
* feat(dashboard): rollup table + cron-driven aggregation for dashboard
Mirror the per-runtime rollup in `task_usage_daily` (migrations 073/077/082)
to remove the per-request raw aggregation the dashboard was doing.
Migration 084 adds:
- `task_usage_dashboard_daily` keyed on
(bucket_date, workspace_id, agent_id, project_id, model) — the
dimensions the dashboard actually queries, with project_id nullable
via UNIQUE NULLS NOT DISTINCT (PG15+) so "no-project" buckets
upsert cleanly.
- `task_usage_dashboard_rollup_state` watermark table.
- `task_usage_dashboard_dirty` invalidation queue.
- Triggers on agent_task_queue DELETE, task_usage DELETE, and
issue.project_id UPDATE — the cases the updated_at watermark can't
see. The project_id trigger re-attributes existing rollup rows when
a user moves an issue across projects.
- `rollup_task_usage_dashboard_daily_window(from, to)` —
idempotent recompute primitive (same shape as 077).
- `rollup_task_usage_dashboard_daily()` cron entry — own advisory
lock (4244) so it serialises independently of the runtime rollup.
- `task_usage_dashboard_rollup_lag_seconds()` health helper.
Sqlc queries `ListDashboardUsageDailyRollup` /
`ListDashboardUsageByAgentRollup` read from the new table; the handler
dispatches between rollup and raw on a separate
`UseDailyRollupForDashboard` config flag
(`USAGE_DASHBOARD_ROLLUP_ENABLED` env). Same fail-safe default (false →
raw) so operators can roll out independently of the per-runtime flag.
Bucket date is UTC (the dashboard aggregates across runtimes that may
sit in different tzs; there's no single correct local boundary).
Adds `cmd/backfill_task_usage_dashboard_daily` mirroring the existing
per-runtime backfill — operator runs it once before flipping the flag.
Tests: - TestDashboardEndpoints now also exercises the rollup read path
(raw vs. rollup, same project-scoped totals).
- TestDashboardRollupReattributesOnProjectChange verifies the
issue.project_id trigger enqueues both old + new buckets and the
next rollup tick zeroes the old project + populates the new one.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard-rollup): close two invalidation gaps
Two leak paths missed by migration 084 review:
1. Issue cascade DELETE — the atq BEFORE DELETE trigger runs AFTER the
issue row is gone, so `LEFT JOIN issue` returns NULL project_id and
the original-project bucket never gets cleared (issue 077 calls this
out for the runtime rollup but didn't need to act on it). Adds an
`issue BEFORE DELETE` trigger that enqueues using OLD.project_id
while the issue row is still readable.
2. `LinkTaskToIssue` (quick-create task attaching to a real issue post-
completion) UPDATEs `agent_task_queue.issue_id` from NULL to a real
id. Migration 084 only watched DELETE on atq, so usage already
rolled up under the no-project bucket stayed attributed to NULL
forever. Extends the atq trigger to fire on UPDATE OF issue_id too,
enqueueing both OLD (NULL project) and NEW (linked issue's project).
Tests: - TestDashboardRollupClearsOnIssueDelete asserts rollup row drops to
zero after issue delete + rollup tick.
- TestDashboardRollupReattributesOnLinkTaskToIssue verifies tokens
move from the NULL bucket to the project bucket after the UPDATE.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): make GitHub repo list scrollable in Add Resource popover
When a workspace has many GitHub repos, the list in the Add Resource
popover extended beyond the visible area with no way to scroll. Add
max-h-48 overflow-y-auto to the repos container to enable scrolling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): make GitHub repo list scrollable in create project modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The GitHub App integration code reads these two env vars and only enables
the Connect flow when both are set. .env.example never listed them, and
docker-compose.selfhost.yml did not forward them into the backend
container, so self-hosters following the integration docs had no working
way to turn the feature on.
MUL-2107
Co-authored-by: multica-agent <github@multica.ai>
Notifications from system actors (e.g. GitHub PR closed) were rendering
with an "S" initials fallback. The avatar now shows the Multica icon
when actor_type === "system", matching the platform's brand.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): only auto-close issue when all linked PRs have resolved
Previously, the webhook handler unconditionally moved an issue to `done`
as soon as a single linked PR was merged. If a second PR was also linked
to the same issue and still open / draft, the issue would close before
the work was actually finished.
Add `CountOpenSiblingPullRequestsForIssue` and gate the auto-status
transition on it: a merged PR advances its linked issues only when no
sibling PR linked to the same issue is still in flight. Issues stay put
while siblings are open or draft, and the merge that resolves the last
in-flight PR is the one that closes the issue.
Adds an integration test that opens two PRs against the same issue,
merges the first, asserts the issue stays in_progress, then merges the
second and asserts the issue advances to done.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): re-evaluate auto-close on closed-without-merge events too
GPT-Boy review on #2470: gating only the `state == "merged"` branch left
one ordering hole. PR-A merges first → issue stays in_progress because
PR-B is open; PR-B later closes WITHOUT merging → no event ever re-runs
the auto-close check, so the issue is stuck in_progress.
Generalise the trigger to every terminal PR event (`merged` or `closed`)
and advance the issue only when:
- the issue is not already terminal (done / cancelled);
- no sibling PR is still in flight (open / draft);
- at least one linked PR — current or sibling — actually merged.
Rule (3) preserves "user closed every PR without merging → leave the
issue alone": if no work was delivered, the user decides what to do.
Replace `CountOpenSiblingPullRequestsForIssue` with
`GetSiblingPullRequestStateCountsForIssue`, which returns both the
in-flight count and the merged count in a single roundtrip.
Adds `TestWebhook_ClosedSiblingAfterMerge` (the regression GPT-Boy
flagged) and `TestWebhook_AllClosedWithoutMerge` (the negative case
guarding rule 3). Refactors the multi-PR webhook helper out of the
existing two-merge test so all three multi-PR scenarios share it.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Virtualization and precise deep-link landing have fundamentally opposed
contracts: virtualization uses estimated heights for off-screen items,
deep-link needs real heights for everything above the target. Three
prior fix attempts (initial scrollToIndex race, settle-by-silence
observer, 3-pass cooperative scroll) all tried to satisfy both in one
path and none fully stabilized — code/image/mermaid-heavy comments
kept drifting the target after first landing.
Split by user intent instead:
- highlightCommentId set (user came from inbox to read a specific
comment) -> render flat. Every comment mounts, every height is real,
the target id is in the DOM the instant the effect runs. Native
document.getElementById + el.scrollIntoView({block:'center'}) is
semantically identical to a native <a href="#comment-X"> anchor.
- otherwise -> Virtuoso. Browsing mode keeps the first-paint perf win
from #2413 on long timelines.
Deep-link effect collapses to ~22 lines, matching the pre-virtualization
implementation. A shared renderItem function keeps both render modes
consistent. Removes: bootstrapRef, three-pass scrollToIndex effect,
overflow-anchor:none, scrollPaddingTop on container, scroll-margin-top
on every comment wrapper, virtuosoRef + VirtuosoHandle, initialItemCount
prop, useLayoutEffect.
Mermaid gets a 280px skeleton (web.dev CLS guidance) plus a
sessionStorage layout cache keyed by chart-text hash, so the 0px ->
real-height shift no longer drifts the surrounding layout — useful for
both render modes, deep-link or browsing. Pattern matches ant-design/x
#1497 which fixes the same Mermaid drift in their own stack.
Auto-expand a folded resolved thread when the deep-link target is a
reply inside it; without this the target reply stays collapsed and the
user sees only the resolved-bar.
Net: +131 / -245 in issue-detail.tsx. Tests added for the
resolved-thread-reply auto-expand path.
Known follow-ups:
- <ReadonlyImage> aspect-ratio for image CLS (same class as Mermaid).
- Layout heisenbug (page width "abnormal" without devtools open) is
orthogonal to deep-link and survives this PR; needs separate triage.
- 500+ comment cold mount in deep-link mode pays full markdown+lowlight
cost; GitHub takes the same hit and we accept it.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The card displayed a per-installation row (avatar + account_login +
"User|Organization · connected <date>") plus a disconnect button. In
practice the title regularly fell back to "unknown" because the server's
fetchInstallationAccount call doesn't sign App JWT, and the
account-level framing also leaked GitHub's data model into the UX —
users care about which repos are wired up, not which GitHub account the
App is installed on.
Collapse the card to: GitHub mark + description + Connect button (plus
the "not configured" hint and role gate). Existing installations stay
fully manageable from GitHub's own settings page, reachable via Connect.
Removes:
- installation list + disconnect button + handleDisconnect
- useQueryClient / Trash2 / githubKeys imports
- five now-dead i18n keys (loading / empty / connected_at /
toast_disconnected / toast_disconnect_failed) in en + zh-Hans
The two issue-detail surfaces that stop a single agent task — the
sticky AgentLiveCard banner and the active rows inside
ExecutionLogSection — cancelled on the first click. Task
cancellation is irreversible, and a misclick on a long-running run
was costly with no way to recover.
Both entry points now route through a shared
TerminateTaskConfirmDialog (AlertDialog with destructive confirm),
mirroring the pattern the Agents list row actions already use for
the "cancel all tasks" flow. The running-state note about a few
seconds to fully halt is only shown when the task is actually
running or dispatched.
Chat window pending-pill Stop is intentionally not affected — it
is fire-and-forget with the UI clearing optimistically, and a
confirm step there would interrupt chat flow.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The 'Learn more' link on the Runtimes page pointed to
https://multica.ai/docs/runtimes which returns 404. The docs page is
published at /docs/daemon-runtimes.
* feat(github): GitHub App backend for PR ↔ issue linking
- New tables: github_installation (workspace ↔ App install), github_pull_request (mirrored PR state), issue_pull_request (M:N link).
- Webhook handler verifies HMAC-SHA256, upserts PR rows, parses issue identifiers from PR title/body/branch and auto-links them. Merging a linked PR moves the issue to done.
- Connect/setup endpoints power the zero-config "Connect GitHub" install flow; state token is HMAC-signed so the setup callback can recover the workspace.
- Workspace-scoped admin routes for listing/disconnecting installations, plus a per-issue `pull-requests` list endpoint.
Co-authored-by: multica-agent <github@multica.ai>
* feat(github): UI for connecting GitHub and viewing linked PRs
- Settings → Integrations: new tab with Connect GitHub / installations list / disconnect, gated on the deployment having the App configured.
- Issue detail sidebar: Pull requests section showing linked PR title, repo, state (open/draft/merged/closed), and author, with deep link to GitHub.
- Real-time refresh: github_installation:* and pull_request:* events invalidate the matching TanStack Query caches.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): address review — null actor, role gating, configured guard, scoped uninstall broadcast
- listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry.
- IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins.
- backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set.
- DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly.
- tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace).
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): i18n the new GitHub UI strings to satisfy lint
CI flagged every literal string in the Integrations tab, the Pull requests
sidebar section, and the per-PR row label. Move them through useT() and
add the matching `integrations.*` block to settings.json (en / zh-Hans)
plus `detail.section_pull_requests` / `detail.pull_request_state_*` /
loading + empty copy under `issues.json`.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Follow-ups to #2444:
- ServeFile refuses keys ending in .meta.json so the sidecar JSON isn't
a stable read API. Sits before any disk work so a crafted
.meta.json sibling can't trigger an out-of-tree read.
- ServeFile rejects paths that resolve outside uploadDir (via
filepath.Rel) before readLocalMeta runs. http.ServeFile's own ..
guard fires later on r.URL.Path, but readLocalMeta would otherwise
do a stray disk read on <some-path>.meta.json before the 400 lands.
- Upload only writes a sidecar when filename is non-empty. ServeFile
only reads the filename anyway, so a content-type-only sidecar was
dead disk weight.
- Drop the dead json.Marshal error branch — marshaling two strings
cannot fail.
Three new tests cover sidecar suffix rejection, the traversal guard,
and the no-filename Upload short-circuit.
Co-authored-by: multica-agent <github@multica.ai>
LocalStorage.ServeFile delegated straight to http.ServeFile without
setting Content-Disposition, so downloads of local-storage attachments
landed on disk under the UUID-based storage key instead of the human
filename the uploader had chosen. The S3 backend already sets
Content-Disposition on PutObject (s3.go:186-187), so the local backend
was the only one losing the original filename — a sibling asymmetry
that's been there since multi-backend support landed.
Upload now writes a sidecar <key>.meta.json beside the data file
capturing the original filename and sniffed content type. ServeFile
reads the sidecar when present and sets Content-Disposition using the
existing sanitizeFilename + isInlineContentType helpers, mirroring the
S3 inline/attachment decision exactly. Uploads from before this lands
have no sidecar and fall through to the previous behavior. Delete now
removes the sidecar alongside the data file so the upload directory
doesn't grow orphans.
Closes#2442
The first file upload in a brand-new chat showed the blob preview for
a moment and then disappeared — the upload looked like it had failed
even though the attachment was actually saved.
Root cause: `<ContentEditor key={draftKey}>`. `draftKey` includes
`activeSessionId`, and `handleUploadFile` (chat-window.tsx) awaits
`ensureSession("")` before forwarding the file to the upload handler.
Lazy-create flips `activeSessionId` from null to a uuid mid-upload,
which changes `draftKey`, which forces React to remount the editor.
The blob image node inserted by `uploadAndInsertFile` was on the old
editor instance; by the time the upload settled, the swap-to-CDN-URL
walk in file-upload.ts couldn't find the blob src in the new editor
and finally `URL.revokeObjectURL` released the blob — broken image.
The create-issue modal has the same draft-store pattern but does not
hit this bug because it never sets a `key` on its ContentEditor; the
editor lives for the lifetime of the modal regardless of draft churn.
Split the two concerns the previous `draftKey` was conflating:
- `draftKey` (zustand storage key) keeps `activeSessionId` so each
session gets its own draft slot — unchanged behaviour.
- `editorKey` (React identity key) drops `activeSessionId` and only
varies on `selectedAgentId`, which is the actual signal Tiptap's
Placeholder needs to refresh on agent switch.
Now the editor stays mounted across the lazy session creation. The
blob preview survives long enough for the swap to find it, and the
user sees the image render normally on the very first upload of a new
chat.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(modals): correct text input height in issue creation dialog
Fixed text input height for both agent and manual create issue dialogs:
- Agent dialog: added flex to outer div and flex-1 to inner div
- Manual dialog: added flex to description container and flex-1 to editor
Fixed: #2433
* fix(editor): make EditorContent a proper flex container
- EditorContent: flex flex-1 flex-col
- Remove min-height: 100% from .ProseMirror CSS
- Let flex-grow handle height consistently across the chain
Fixed: #2433
---------
Co-authored-by: ayakabot <ayakabot@seepine.com>
* refactor(feedback): replace generic description with brand-colored GitHub CTA
The Feedback modal previously rendered three lines of grey copy before the
editor — title, description, and the GitHub hint from #2451. The hint blended
into the description, defeating its purpose of nudging users toward a tracked
channel.
Drop the generic description (placeholder already explains what to type) and
restyle the hint so GitHub itself is the only brand-coloured anchor. The
shorter sentence ("Want faster traction? Head to GitHub") puts the link at
the natural end-of-line fixation point, where the colour shift actually
registers.
i18n splits into prefix + link (suffix would be empty), avoiding the
sentence-order brittleness that 3-key splits usually introduce.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* copy(feedback): expand GitHub hint to highlight discussion as well
Reviewer feedback: "faster traction" only signals speed; users also care about
having an open back-and-forth on a tracked thread. Update the hint to surface
both benefits without lengthening the line meaningfully.
- EN: "Want faster handling and open discussion? Head to GitHub"
- ZH: "想被更快处理、参与讨论?请去 GitHub"
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>
Replaces #2452's first attempt (placeholder-freeze, 800ms blank
window) and the multi-observer settle pipeline from #2449. Both
were trying to land the target with a single perfectly-timed scroll,
which doesn't compose with how virtualization actually works.
The non-virtualized version of this code, pre-#2413, was 12 lines:
one el.scrollIntoView once timeline.length > 0 && !loading. That
worked because every comment was in the DOM, so the target's
absolute position was real, not estimated. Virtualization breaks
that invariant — Virtuoso renders a window, fills the rest with
spacer heights derived from estimates, and the target's offset is
spacer-sum until each above-target item is mounted and measured for
the first time. Those measurements arrive in waves: viewport mount,
ResizeObserver pass, markdown render, lowlight code highlight,
image load. Each wave updates spacers and shifts the target's
offset by tens to hundreds of pixels.
The previous two attempts both tried to detect "settle" and land
once. ResizeObserver on the target watches the symptom, not the
cause (#2449). Rendering placeholders to freeze the cause shows
800ms of blank where comments should be (#2452 v1).
This rewrite cooperates with Virtuoso's own measure→correct loop
instead of trying to outrun it. Three scrollToIndex calls — t=0,
t=120 (after the first measurement wave), t=500 (after markdown /
lowlight settle) — let the convergence narrow on each pass. Each
call uses whatever spacer heights are current; differences across
passes are typically a few pixels (cold viewport) to a few dozen
(big code blocks), not the full-spacer drift that motivated
placeholders. Visually it reads as a single instant scroll with at
most a couple of subtle re-centerings, not a re-jump.
initialTopMostItemIndex stays — it's the only API that anchors
position *before* first paint, and it's the reason cold-start
deep-links from inbox land at the target without a visible "scroll
from top". Captured exactly once via a useRef one-shot following
React's documented "avoid recreating ref contents" idiom, so #458's
persistent-anchor reset behavior can't trip. Crucially we now
spread-on-defined rather than passing `={undefined}` — react-virtuoso
crashes with "Cannot read properties of undefined (reading 'index')"
on the latter because the library accesses .index on the prop without
a null guard.
Net delta vs main: −86 lines. Deletes ~150 lines of the #2449
MutationObserver/ResizeObserver settle pipeline plus this PR's
prior placeholder/deepLinking/flushSync machinery, replaces with
~30 lines of straightforward effect + bootstrap ref. The whole
deep-link path is now smaller than the original pre-virtualization
version was, because the convergence loop is explicit and the
correctness story doesn't require auxiliary state.
Refs: react-virtuoso #458 (initialTopMostItemIndex anchor reset),
#883 (initial scroll race), #1083 (scrollTop model divergence vs
native scrollIntoView).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a small CTA below the Feedback modal description that links to
github.com/multica-ai/multica/issues for users who want a tracked, public
channel. The in-app feedback form still serves vague impressions and
weekly-aggregated input; GitHub is for concrete bugs, feature requests, and
discussion that benefits from community visibility.
i18n covers en + zh-Hans following the conventions.zh.mdx voice guide
(full-width punctuation, ASCII ellipsis, spaces around Latin terms).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(plans): chat attachment & image support implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(db): add chat_session_id/chat_message_id to attachment
Co-authored-by: multica-agent <github@multica.ai>
* feat(db): sqlc — chat_session_id on CreateAttachment + LinkAttachmentsToChatMessage
Co-authored-by: multica-agent <github@multica.ai>
* feat(file): upload-file accepts chat_session_id form field
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): SendChatMessage links uploaded attachments to the new message
Co-authored-by: multica-agent <github@multica.ai>
* feat(api): uploadFile accepts chatSessionId; sendChatMessage accepts attachmentIds
Co-authored-by: multica-agent <github@multica.ai>
* feat(core): useFileUpload supports chatSessionId context
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): support paste/drag/upload attachments in chat input
Co-authored-by: multica-agent <github@multica.ai>
* test(e2e): chat input attachment upload + send round-trip
Co-authored-by: multica-agent <github@multica.ai>
* chore(chat): keep lazy-created session title empty so untitled fallback localizes
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address review — dedupe ensureSession + parse upload response
- chat-window: cache in-flight createSession promise in a ref so a file drop
followed by a quick send no longer spawns two sessions (and orphans the
attachment on the losing one).
- Attachment type + EMPTY_ATTACHMENT + AttachmentResponseSchema: include the
new chat_session_id / chat_message_id fields the server now returns.
- uploadFile: route the response through parseWithFallback so a malformed
body returns EMPTY_ATTACHMENT instead of an undefined-keyed Attachment,
matching the API boundary rule.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address PR #2445 review — test ctx, send gating, attachment surface
1. Backend test was 400ing because the handler reads workspace from
middleware-injected ctx, and `newRequest` only sets the header. Helper
`withChatTestWorkspaceCtx` mirrors the agent-access-test pattern and
loads the member row + SetMemberContext before invoking the handler.
2. Attachment metadata now flows end-to-end:
- new sqlc `ListAttachmentsByChatMessageIDs` (batch lookup, mirrors the
comment-side query)
- `chatMessageToResponse` takes `attachments` and `ChatMessageResponse`
surfaces them — same shape as CommentResponse
- `ListChatMessages` loads them via a new `groupChatMessageAttachments`
helper so the chat bubble can render file cards
- daemon claim path pulls `ListAttachmentsByChatMessage` for the latest
user message and ships `ChatMessageAttachments` to the daemon
- `buildChatPrompt` lists id+filename+content_type and instructs the
agent to `multica attachment download <id>` — fixes the private-CDN
expiring-URL problem where the markdown URL would have expired by
the time the agent acts
- TS `ChatMessage` gains an optional `attachments` field
3. Chat composer now blocks send while uploads are in flight:
- `pendingUploads` counter increments in handleUpload, SubmitButton
uses it to disable
- handleSend also gates on `editorRef.current.hasActiveUploads()` to
catch the Mod+Enter path that bypasses the button
- new vitest covers the "drop large file → immediate send" scenario
where attachment id would otherwise be silently dropped
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* chore: drop implementation plan doc
Process artefact, not something the repo needs to keep.
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>
The earlier deep-link fix (0d0d100e) used a fixed 20-frame rAF poll to
wait for Virtuoso to mount the target before handing off to the
browser's native scrollIntoView. That approach failed under three
conditions all reproduced on the 500-comment perf fixture:
1. Items near the bottom of long lists: Virtuoso's estimate→mount→
ResizeObserver→correction sequence stretches past 320ms; the
poll gave up and set highlight without scroll.
2. Tall markdown/code-block comments: the target mounted within the
poll window but its measured height was not yet final (lowlight
was still highlighting). scrollIntoView landed on the not-yet-
reflowed card; the card grew a moment later and dragged the
target out of view.
3. Late image loads or any post-mount layout shift inside the
timeline: the browser's built-in CSS scroll-anchoring silently
nudged scrollTop after we had already finished, putting the
target back off-center.
The root cause is the same race that every variable-height
virtualizer has — official react-virtuoso #1263 calls it out as
intentional, and #1296 shows even Virtuoso's own `scrollIntoView({done})`
callback is unreliable across the same scenarios. The fix is
virtualizer-agnostic: don't trust *any* "we landed" signal the
virtualizer gives you. Wait for the real DOM node to stop reflowing
before handing off to the browser.
Four phases now:
Phase 1 (coarse): virtuosoRef.scrollToIndex only to *mount* the
target. The scroll position it produces is discarded.
Phase 2 (adopt): MutationObserver on the scroll container picks
up the target node as soon as it enters the DOM.
Phase 3 (settle): a ResizeObserver on the target with a
"settle-by-silence" timer — every RO tick re-arms a 120ms idle
window; when the window elapses with no further ticks the card
is treated as stable. Baseline 150ms timer so a fully-static
card (or test env with stubbed RO) still proceeds.
Phase 4 (land): native el.scrollIntoView({block:'center'}), then
light the highlight on `scrollend` (or a 200ms fallback for
Safari < 17.4 and jsdom, both of which never fire scrollend).
Hard 2.5s cap on the whole pipeline so a comment whose images load
indefinitely doesn't leak observers; in that case we still attempt a
final scroll with whatever's measured and flash the highlight so a
manual scroll lands on a marked card.
CSS partner: `overflow-anchor: none` on the scroll container disables
the browser's automatic re-anchoring on layout shifts above the
viewport. Without this even a perfectly-landed scrollIntoView can be
silently nudged off-target by a late ResizeObserver pass on a
comment above the viewport.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes three gaps in the Linux desktop build that combined to render the
Multica window with the system Settings (gear) icon on Ubuntu:
1. Force `linux.executableName: multica` so the scoped npm name
`@multica/desktop` stops leaking into `executableName`, the `.desktop`
filename, the `Icon=` field, and `/usr/share/icons/hicolor/*/apps/*.png`.
The leading `@` in the previously-generated `@multicadesktop` violates
freedesktop desktop-entry naming, breaking GNOME's window↔.desktop
association and forcing the theme-default icon. (The artifact-filename
side of the same scoped-name leak was already patched in 10618b1f;
this commit closes the desktop/icon-identity side.)
2. Always set `BrowserWindow({ icon })` on Linux — previously gated on
`is.dev`. AppImage direct-launches never install the `.desktop` entry,
so without an explicit window icon the WM has no other path to the
bundled image. The resolved path now points into `app.asar.unpacked/`
(matching the existing `bundledCliPath()` convention in
`daemon-manager.ts`) since the Linux native icon code path requires a
real filesystem path, not an asar-internal one.
3. Pin `linux.desktop.entry.StartupWMClass: Multica` explicitly. The
value already matches the productName-derived default, so this is a
build-time no-op today, but it makes the WM_CLASS↔StartupWMClass
matching contract auditable in config — future changes to
`productName` or `app.setName()` now show up as a diff against this
file instead of silently re-breaking the icon association.
Fixes https://github.com/multica-ai/multica/issues/2424.
* docs(self-hosting): document Caddy WebSocket essentials
Add a single-domain Caddy example and harden the separate-domain one
with the WebSocket route a self-hoster actually needs:
- handle /ws* (prefix match, not exact `/ws`) so future path variants
don't fall through to the frontend block
- flush_interval -1 inside the WS reverse_proxy, otherwise frames sit
behind Caddy's default flush window and surface as "comments only
appear after a page refresh"
Both gaps were hit by a self-hosted user on a single-domain Caddy
deployment, and neither was documented.
Co-authored-by: multica-agent <github@multica.ai>
* docs(self-hosting): tighten Caddy /ws matcher to avoid catching `/ws-*` slugs
Use a named matcher `path /ws /ws/*` instead of the over-broad `handle /ws*`.
Caddy's `*` is a path-glob without segment boundary, so `/ws*` would also
match unrelated paths like `/ws-foo` — which is a legitimate workspace URL
under the current reserved-slug rules (only the exact `ws` slug is reserved).
Per GPT-Boy review on PR #2436.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The Diagnostics card's Visibility section had a two-line layout — icon +
label on top, descriptive hint underneath — which made it look noisy next
to the compact Timezone / CLI sections. Move the hint into a tooltip on
hover and collapse the buttons into a tight segmented-toggle pair
matching the runtimes-page Mine/All filter pattern. Readout side mirrors
the change: chip-only, full description on hover.
Co-authored-by: multica-agent <github@multica.ai>
* feat(runtime): visibility (public/private) gate on CreateAgent / UpdateAgent
Closes the hole where a plain workspace member could pick another member's
runtime in the Create Agent dialog and bind an agent to it — the backend
wasn't checking runtime ownership, so the agent ran on someone else's
hardware / tokens. Reported on GH #1804.
Schema
- Migration 083 adds agent_runtime.visibility ('private' default, 'public')
with a CHECK constraint. Existing rows default to private — same
ownership semantics as before, no behavior change for legacy data.
Backend
- canUseRuntimeForAgent predicate: allow when caller is workspace
owner/admin, the runtime owner, or the runtime is public.
- CreateAgent and UpdateAgent both gate on it: UpdateAgent matters because
a plain member could otherwise create on their own runtime, then re-bind
to a private one.
- PATCH /api/runtimes/:id accepts { visibility } — owner/admin only,
validated against the same private/public allow-list.
Frontend
- Create-agent dialog renders other-owned private runtimes disabled with a
Lock badge + tooltip explaining who to ask.
- Inspector runtime-picker disables the same set so re-binding fails
the same way at the UI layer.
- Runtime detail diagnostics gains a Visibility editor (owner/admin) or
read-only chip (everyone else).
- Runtime list shows a private/public chip next to the name.
Tests
- Go: canUseRuntimeForAgent truth table; CreateAgent / UpdateAgent
end-to-end gate tests (admin / runtime owner / plain member);
PATCH visibility owner / admin / member / invalid-value coverage.
- Vitest: create-agent dialog disabled state on private/public runtimes,
default-runtime selection skips locked rows; runtime detail visibility
editor → mutation, read-only fallback.
Migrating runtimes: existing rows default to private to preserve the
"owner only" status quo. Owners switch to public via the detail page
diagnostics card.
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtime): apply timezone+visibility atomically; don't seed locked template runtime
Two issues surfaced in review of MUL-2062:
1. PATCH /api/runtimes/:id ran the timezone branch first, which:
- returned early on a tz no-op, silently dropping a concurrent
`visibility` patch in the same body;
- committed the timezone mutation (+ usage rollup rebuild) before
validating visibility, so an invalid visibility left the row
half-updated.
Validate every field first, then run the mutations in order. The
no-op short-circuit now only triggers when nothing else is requested.
2. The Create Agent dialog in duplicate mode unconditionally seeded
`template.runtime_id` as the selected runtime, even when that runtime
is now private and owned by someone else — the user saw a selected
row they couldn't submit (Create → backend 403). Fall back to the
first usable runtime when the template's runtime is locked, and gate
the Create button on `selectedRuntimeLocked` as defense in depth.
Tests:
- Go: TestUpdateAgentRuntime_CombinedPatchAppliesBoth (tz no-op +
visibility flip), TestUpdateAgentRuntime_InvalidVisibilityDoesNotMutateTimezone
(atomic-fail invariant).
- Vitest: duplicate template pointing at a locked runtime now seeds
the first usable one; Create button stays disabled when no usable
alternative exists.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* perf(views): virtualize issue detail timeline with react-virtuoso
The unvirtualized timeline at issue-detail.tsx full-mounted every
entry, freezing first paint for several seconds at 500+ comments
(markdown parse + lowlight per CommentCard on mount). Production p99
is ~30 comments but the all-time max is ~1.1k and the server hard-caps
at 2000 — long-tail issues were unusable.
Swap the inline `.map` for `<Virtuoso customScrollParent>` driven by a
flattened TimelineItem discriminated union. TanStack Query stays the
source of truth; existing memo machinery (`prevThreadRepliesRef`,
`EMPTY_REPLIES`) and WS handlers are untouched. `followOutput="auto"`
matches Slack/Discord — users at the bottom auto-follow new comments,
users mid-scroll are not yanked back down.
Comment drafts move to a new persisted Zustand store
(`comment-draft-store`) so virtualization-driven unmount can no longer
drop in-progress edits or new comments. Hydrates via ContentEditor
`defaultValue`, flushes on update / blur / visibilitychange.
Deep-link from inbox is rewritten from `getElementById` +
`scrollIntoView` to `virtuosoRef.scrollToIndex` with a double-rAF
mitigation for the Virtuoso #883 initial-scroll race. Highlight flash
bumped 2s→3s to outlast mount latency on cold cards.
Cmd-F shows a once-per-session toast on long timelines since browser
find-in-page can't reach off-screen virtualized items. Real in-app
search lands in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(views): repair deep-link scroll and isolate comment drafts
The first virtualization landing had three latent issues that runtime
testing on perf fixtures (10 → 5000 comments) exposed:
1. Deep-link landing position was wrong by ~380px on every issue.
In customScrollParent mode Virtuoso computes scrollTop from the
list's internal coordinate space only — it doesn't account for
sibling content (title editor, description, sub-issues, agent
card) sitting above the list inside the same scroll parent. The
useEffect now uses Virtuoso scrollToIndex only to MOUNT the
target into the DOM, then polls a `data-comment-id` anchor and
delegates positioning to the browser's scrollIntoView, which
honors getBoundingClientRect and lands accurately every time.
2. Scroll-up was being yanked back to the deep-link anchor on every
ResizeObserver tick. Root cause was `followOutput="auto"`, which
stays "stuck to bottom" once the deep-link lands there and resets
scrollTop to maxScrollTop on each height change. Issue detail is
document-shaped, not chat-shaped, so removing followOutput
altogether is the right tradeoff. Likewise `initialTopMostItemIndex`
acts as a persistent anchor in customScrollParent mode (Virtuoso
#458) — dropped entirely and replaced with imperative scroll.
`defaultItemHeight` is also dropped so Virtuoso probes real
heights instead of estimating + correcting visually.
3. Reply-comment deep-links from the inbox would short-circuit
because the reply id isn't in the flat items[] array. Added a
replyToRoot map so deep-link falls back to the enclosing thread's
root index, scrolls there, and lets the reply's own ring fire
once the thread is in view.
Also fixes a latent cross-issue draft leak in `<CommentInput>`:
web's /issues/[id] route doesn't remount IssueDetail on issueId
change, so without an explicit `key={id}` the editor kept the
previous issue's in-memory content and the next keystroke would
flush it under the new issue's draft key. The same fix incidentally
repairs the pre-existing "submit composer from issue A while viewing
issue B" submit-target bug.
Highlight UX polish: bg-brand/5 was too faint to notice; ring upgraded
to ring-brand/60 as the sole signal. transition-colors didn't actually
animate ring/box-shadow — switched to transition-shadow duration-500
ease-out so highlight has visible fade in / fade out. Flash duration
3s → 4s. Polling failure now still sets highlight + warns so a manual
scroll to the target still flashes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summarizes the 24 PRs landed since v0.2.29 in EN and ZH changelog
data, organized into features, improvements, and fixes.
Co-authored-by: multica-agent <github@multica.ai>
Three user reports converge on the same Windows-shell encoding bug:
- #2198 / #2236 — Chinese, Codex on Win11. Comments / descriptions
generated by the agent arrive as `?`.
- #2376 — Cyrillic, non-Codex agent ("Ops Lead") on Win11 Desktop.
Title preserved (argv → CreateProcessW UTF-16), description / agent
reply garbled (stdin → shell-codepage re-encoding).
woodcoal's independent diagnosis on #2198 confirms the root cause:
Windows PowerShell 5.1's `$OutputEncoding` defaults to ASCIIEncoding
when piping to a native command, so non-ASCII bytes are silently
replaced with `?` before they reach `multica.exe`. The CLI's stdin
parsing is fine; the bytes are corrupted upstream, in the agent's
shell layer.
This PR ships the fix that supersedes the codex-only attempt in
PR #2265 (which is closed in favour of this one):
## CLI
Add `--content-file <path>` to `multica issue comment add` and
`--description-file <path>` to `multica issue {create,update}`. The
CLI reads bytes off disk via `os.ReadFile` and skips the shell
entirely; UTF-8 survives end-to-end regardless of `$OutputEncoding`
or `chcp`. The three input modes (`--content`, `--content-stdin`,
`--content-file`) are mutually exclusive.
## Runtime config
`buildMetaSkillContent`'s Available Commands section is rewritten as a
neutral three-mode menu. The previous unconditional "MUST pipe via
stdin" / `--description-stdin` mandate (over-spread from #1795 /
#1851's Codex-multi-line fix) is gone for non-Codex providers; the
strong directive now lives only in the Codex-Specific section, which
branches on host:
- Codex / Linux+macOS: `--content-stdin` + HEREDOC (preserves MUL-1467
fix against codex's literal `\n` habit).
- Codex / Windows: `--content-file` (PowerShell ASCII pipe is the
exact bug we're patching).
## Per-turn reply template
`BuildCommentReplyInstructions` now takes a provider arg and branches
provider × OS:
- Windows + any provider → `--content-file` (the bug is shell-layer,
not provider-layer; #2376 shows non-Codex agents on Windows also
hit it). All providers write a UTF-8 file with their file-write tool
and post via `--content-file ./reply.md`.
- Linux/macOS + Codex → stdin/HEREDOC (MUL-1467 protection).
- Linux/macOS + non-Codex → lightweight pre-#1795 inline
`--content "..."`. The CLI server-side decodes `\n`, so escaped
multi-line works; the agent retains stdin / file as escape hatches
for richer formatting.
`BuildPrompt` and `buildCommentPrompt` gain a `provider` arg;
`daemon.runTask` already has it in scope.
## Tests
- `TestResolveTextFlag` — file-source verbatim with non-ASCII
(`标题 / Заголовок / 中文段落`), missing-file error, empty-file
rejection, three-way mutual exclusion.
- `TestInjectRuntimeConfigAvailableCommandsIsNeutral` — every
non-Codex provider × {linux, darwin, windows} pins the three-mode
menu present + over-spread "MUST stdin" substrings absent.
- `TestInjectRuntimeConfigCodexLinuxEmphasizesStdin` +
`TestInjectRuntimeConfigCodexWindowsUsesContentFile` — Codex
section's per-OS branch.
- `TestBuildCommentReplyInstructionsCodexLinux` +
`TestBuildCommentReplyInstructionsNonCodexLinux` +
`TestBuildCommentReplyInstructionsWindowsUsesContentFile` — the
reply-template provider × OS matrix.
- `TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin` — end-to-end
AGENTS.md / CLAUDE.md on Windows has no prescriptive stdin
directive, for claude / codex / opencode.
`go test ./...` and `go vet ./...` clean.
Closes#2198, #2236, #2376.
Co-authored-by: multica-agent <github@multica.ai>
* fix(core): namespace recent-issues by workspace id in state
The recent-issues store was using createWorkspaceAwareStorage, which
namespaces the storage key by the current slug. That broke whenever a
setter ran before WorkspaceRouteLayout's mount-effect set the slug —
child effects fire before parent effects in React, so recordVisit from
issue-detail wrote to the un-namespaced bare key, leaking visits across
workspaces. The /<slug>/issues page then fanned out a per-id GET for
each leaked id, mostly 404s.
Move the namespacing into the store state itself (byWorkspace keyed by
wsId), so reads/writes pick the right bucket at call time and don't
depend on a singleton being set before module hydration. Drop the
storage-level namespacing and the rehydration registration for this
store.
Add pruneWorkspaces to evict buckets for workspaces the user is no
longer a member of, wired into useDashboardGuard so it runs whenever
the workspace list resolves. As a defense against the prune never
firing, cap the total tracked workspaces at 50 (LRU on oldest visit).
Bump persist version to 1; the v0 entries don't know which workspace
they belonged to, so migrate drops them and the cache repopulates as
the user visits issues.
* fix(core): fail closed on null slug in workspace-aware storage
createWorkspaceAwareStorage used to fall back to the un-namespaced bare
key when no workspace was active. That fallback let any setter firing
before WorkspaceRouteLayout's mount-effect (e.g. a child component's
own mount-effect) leak workspace-scoped data into a global slot
visible to every workspace. Initial zustand persist hydration also ran
in this null-slug window, so every store would read the polluted bare
key on first load.
Drop the fallback: null slug → getItem returns null, setItem/removeItem
are no-ops. Stores still get a correct read via their registered
rehydrate fn once setCurrentWorkspace fires. The remaining nine stores
using this storage no longer rely on the bare-key path either; their
data has always been intended to be workspace-scoped.
---------
Co-authored-by: YYClaw <yyclaw0@gmail.com>
* fix(attachments): re-sign CloudFront download URLs at click time
The attachment download buttons opened `download_url` directly from cached
timeline/comment payloads. The signed URL is valid for 30 minutes, so a page
left open past that window would 403 with `AccessDenied` (MUL-2038 /
GitHub #2397).
- Add `GET /api/attachments/{id}` client method that re-signs on every call,
validated by a stricter `AttachmentResponseSchema` (enforces `url`,
`download_url`, `filename` so a malformed response degrades to the
EMPTY_ATTACHMENT record instead of opening `undefined`).
- Introduce `useDownloadAttachment` hook with two execution shapes:
- Web: synchronously open `about:blank` inside the click gesture to keep
popup activation, then hydrate `location.href` after the fetch. Cannot
pass `noopener` here — HTML spec dom-open step 17 makes that return
null.
- Desktop: skip the placeholder (Electron's setWindowOpenHandler rejects
about:blank) and hand the fresh URL to `openExternal`.
- Wire the hook into the standalone attachment buttons (comment-card) and
the inline `<img>` / file-card buttons inside `ReadonlyContent`. Inline
buttons resolve the attachment id by URL match; external URLs fall back
to `openExternal`.
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): re-sign downloads from ContentEditor file/image NodeViews
The previous commit only wired the click-time fresh-sign through
ReadonlyContent + the standalone attachment list. The Tiptap NodeViews
inside ContentEditor still opened the raw URL with
`window.open(href, "_blank", "noopener,noreferrer")`, leaving two
download surfaces on stale signatures:
- Issue description (always renders via ContentEditor)
- Comment edit mode (transient ContentEditor instance)
- Add AttachmentDownloadContext + AttachmentDownloadProvider so NodeViews
can resolve markdown URLs to an attachment id and call the existing
`useDownloadAttachment` hook. The default fallback (no provider mounted)
hands the raw URL to `openExternal`, keeping non-editor mounts unaffected.
- ContentEditor accepts `attachments?: Attachment[]` and wraps EditorContent
with the provider.
- file-card.tsx and image-view.tsx NodeViews swap their `window.open(...)`
calls for `openByUrl(href|src)` from the provider.
- issue-detail.tsx threads `useQuery(issueAttachmentsOptions(id))` into
ContentEditor for the description.
- comment-card.tsx passes `entry.attachments` to both edit-mode editors.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
The Pi backend hardcoded `--tools read,bash,edit,write,grep,find,ls` in
buildPiArgs. Pi's SDK treats --tools as a restrictive allowlist: only the
listed tools pass through `_refreshToolRegistry()`, silently filtering
out any user-installed extension tools registered via `pi.registerTool()`.
Omitting --tools makes Pi's `allowedToolNames` undefined, so the
`isAllowedTool()` filter becomes a no-op and all tools — built-in and
extension — are available. This matches Pi's standalone behavior.
Users who want to restrict tools can still pass --tools via custom_args
(it is not in piBlockedArgs).
Closes#2379