Compare commits

..

6 Commits

Author SHA1 Message Date
Jiayuan Zhang
3f87b9fdf2 feat(desktop): rebrand Electron.app Info.plist so dev shows Multica Canary
app.setName() can't override the macOS menu bar title or Cmd+Tab label
— those come from CFBundleName baked into the running bundle's
Info.plist. Patch the bundled Electron.app's plist during `pnpm
dev:desktop` so dev launches read "Multica Canary" everywhere, not
"Electron". Idempotent; unlinks before rewriting so we don't mutate a
pnpm-store inode shared with other projects.
2026-04-17 01:19:46 +08:00
Jiayuan Zhang
cafc6f1969 feat(desktop): allow overriding renderer port via DESKTOP_RENDERER_PORT
Lets a second worktree run `pnpm dev:desktop` while a primary checkout
already holds the default Vite dev port 5173 — required to actually
exercise the "Multica Canary" branding in isolation.
2026-04-17 01:14:08 +08:00
Jiayuan Zhang
434aa5b859 feat(desktop): brand dev build as Multica Canary with bundled icon
pnpm dev:desktop ran under the stock Electron name and default icon,
making it indistinguishable from any other Electron dev app in the dock.
Set a Canary app name + userData path and point the macOS dock icon and
BrowserWindow icon at the bundled resources/icon.png so the dev build is
visually branded.
2026-04-17 01:00:19 +08:00
Bohan Jiang
209300c86f fix(server): trigger agent on comments regardless of issue status (#1209)
Previously shouldEnqueueOnComment suppressed agent triggers on done/
cancelled issues, requiring an explicit @mention to resume the
conversation. The gate was non-obvious and confused users who expected
a regular reply to wake the agent up.

Drop the status check — comments are conversational and should wake
the agent up at any status. @mention already bypasses all gates, so
behavior for mentions is unchanged.

Refs multica-ai/multica#1205
2026-04-17 00:57:02 +08:00
Bohan Jiang
3d98f64ea1 Revert "fix(daemon): normalize hostname by stripping .local mDNS suffix (#1070)" (#1207)
This reverts commit 6428a10046.
2026-04-17 00:35:06 +08:00
Jiayuan Zhang
ec30e46947 feat(issues): persist comment collapse state (#1008)
* feat(issues): persist comment collapse state across page reloads

Store collapsed comment IDs in a workspace-scoped Zustand store backed
by localStorage, replacing the transient useState(true) default.
Comments now remember their collapsed/expanded state per issue.

* test(issues): add useCommentCollapseStore mock to issue-detail tests

The existing vi.mock for @multica/core/issues/stores didn't include the
newly exported useCommentCollapseStore, causing CommentCard to throw at
render time.
2026-04-17 00:14:00 +08:00
16 changed files with 170 additions and 173 deletions

View File

@@ -105,7 +105,7 @@ start:
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "Running migrations..."
@cd server && go run ./cmd/migrate up
cd server && go run ./cmd/migrate up
@echo "Starting backend and frontend..."
@trap 'kill 0' EXIT; \
(cd server && go run ./cmd/server) & \

View File

@@ -12,7 +12,10 @@ export default defineConfig({
},
renderer: {
server: {
port: 5173,
// Allow parallel worktrees to run `pnpm dev:desktop` side-by-side
// (e.g. Multica Canary alongside a primary checkout) by overriding
// the renderer port via env. Falls back to 5173 for the common case.
port: Number(process.env.DESKTOP_RENDERER_PORT) || 5173,
strictPort: true,
},
plugins: [react(), tailwindcss()],

View File

@@ -5,7 +5,8 @@
"main": "./out/main/index.js",
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"dev": "pnpm run bundle-cli && electron-vite dev",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
// Rebrand the bundled Electron.app's Info.plist so `pnpm dev:desktop`
// shows "Multica Canary" in the menu bar, Cmd+Tab switcher, and
// Activity Monitor. On macOS these titles come from CFBundleName at
// launch time — `app.setName()` cannot override them at runtime, so
// patching the plist in node_modules is the only working fix.
//
// Idempotent: runs on every dev launch and no-ops once the plist already
// matches. The patch is isolated to this worktree's node_modules — we
// unlink the file before rewriting so we never mutate a pnpm-store inode
// shared with another project.
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
if (process.platform !== "darwin") process.exit(0);
const DESIRED_NAME = "Multica Canary";
const require = createRequire(import.meta.url);
// `require('electron')` returns the path to the executable
// (.../Electron.app/Contents/MacOS/Electron). Walk up to Contents/Info.plist.
const electronBin = require("electron");
const plistPath = resolve(electronBin, "../../Info.plist");
function plistGet(key) {
try {
return execFileSync(
"/usr/libexec/PlistBuddy",
["-c", `Print :${key}`, plistPath],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
).trim();
} catch {
return "";
}
}
function plistSet(key, value) {
try {
execFileSync("/usr/libexec/PlistBuddy", [
"-c",
`Set :${key} ${value}`,
plistPath,
]);
} catch {
execFileSync("/usr/libexec/PlistBuddy", [
"-c",
`Add :${key} string ${value}`,
plistPath,
]);
}
}
if (
plistGet("CFBundleName") === DESIRED_NAME &&
plistGet("CFBundleDisplayName") === DESIRED_NAME
) {
process.exit(0);
}
// Break any pnpm hardlink to the global store: read, unlink, rewrite.
// PlistBuddy would otherwise write through the hardlink and mutate the
// shared store file (and every other project's Electron.app with it).
const original = readFileSync(plistPath);
unlinkSync(plistPath);
writeFileSync(plistPath, original);
plistSet("CFBundleName", DESIRED_NAME);
plistSet("CFBundleDisplayName", DESIRED_NAME);
console.log(`[brand-dev-electron] ${plistPath} → CFBundleName="${DESIRED_NAME}"`);

View File

@@ -1,4 +1,4 @@
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { app, shell, BrowserWindow, ipcMain, nativeImage } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
@@ -6,6 +6,11 @@ import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
// by the `is.dev` branch below.
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
// Run the user's login shell once to recover the real PATH so the bundled
@@ -61,6 +66,9 @@ function createWindow(): void {
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option in
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
@@ -101,9 +109,11 @@ function createWindow(): void {
// is derived from the userData path. (Same approach VS Code uses for
// Stable / Insiders coexistence.)
const DEV_APP_NAME = "Multica Canary";
if (is.dev) {
app.setName("Multica Dev");
app.setPath("userData", join(app.getPath("appData"), "Multica Dev"));
app.setName(DEV_APP_NAME);
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
}
// --- Protocol registration -----------------------------------------------
@@ -141,6 +151,14 @@ if (!gotTheLock) {
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
);
// macOS: replace the default Electron dock icon with the bundled logo
// so the Canary dev build is visually distinct from a stock Electron
// run. `app.dock` is macOS-only — guard the call.
if (is.dev && process.platform === "darwin" && app.dock) {
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
if (!icon.isEmpty()) app.dock.setIcon(icon);
}
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});

View File

@@ -0,0 +1,46 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
/**
* Tracks which comments are collapsed, keyed by issue ID.
* Only collapsed comment IDs are stored — expanded is the default state.
*/
interface CommentCollapseStore {
collapsedByIssue: Record<string, string[]>;
isCollapsed: (issueId: string, commentId: string) => boolean;
toggle: (issueId: string, commentId: string) => void;
}
export const useCommentCollapseStore = create<CommentCollapseStore>()(
persist(
(set, get) => ({
collapsedByIssue: {},
isCollapsed: (issueId, commentId) => {
const ids = get().collapsedByIssue[issueId];
return ids ? ids.includes(commentId) : false;
},
toggle: (issueId, commentId) =>
set((s) => {
const current = s.collapsedByIssue[issueId] ?? [];
const isCurrentlyCollapsed = current.includes(commentId);
if (isCurrentlyCollapsed) {
const next = current.filter((id) => id !== commentId);
if (next.length === 0) {
const { [issueId]: _, ...rest } = s.collapsedByIssue;
return { collapsedByIssue: rest };
}
return { collapsedByIssue: { ...s.collapsedByIssue, [issueId]: next } };
}
return { collapsedByIssue: { ...s.collapsedByIssue, [issueId]: [...current, commentId] } };
}),
}),
{
name: "multica_comment_collapse",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useCommentCollapseStore.persist.rehydrate());

View File

@@ -7,6 +7,7 @@ export {
useViewStoreApi,
} from "./view-store-context";
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
export { useCommentCollapseStore } from "./comment-collapse-store";
export {
myIssuesViewStore,
type MyIssuesViewState,

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
@@ -36,6 +36,7 @@ import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry, Attachment } from "@multica/core/types";
import { useCommentCollapseStore } from "@multica/core/issues/stores";
// ---------------------------------------------------------------------------
// Types
@@ -328,7 +329,10 @@ function CommentCard({
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload(api);
const [open, setOpen] = useState(true);
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
const open = !isCollapsed;
const handleOpenChange = useCallback((_open: boolean) => toggleCollapse(issueId, entry.id), [toggleCollapse, issueId, entry.id]);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
@@ -390,7 +394,7 @@ function CommentCard({
return (
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Collapsible open={open} onOpenChange={setOpen}>
<Collapsible open={open} onOpenChange={handleOpenChange}>
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
<div className="flex items-center gap-2.5">

View File

@@ -228,6 +228,14 @@ vi.mock("@multica/core/issues/stores", () => ({
},
{ getState: () => ({ items: [], recordVisit: mockRecordVisit }) },
),
useCommentCollapseStore: (selector?: any) => {
const state = {
collapsedByIssue: {},
isCollapsed: () => false,
toggle: () => {},
};
return selector ? selector(state) : state;
},
}));
// Mock modals

View File

@@ -147,13 +147,7 @@ func LoadConfig(overrides Overrides) (Config, error) {
// Host info
host, err := os.Hostname()
if err != nil {
host = ""
}
// Normalize mDNS suffix so daemons started via different methods
// (e.g. CLI vs desktop app) always register under the same hostname.
host = strings.TrimSuffix(host, ".local")
if strings.TrimSpace(host) == "" {
if err != nil || strings.TrimSpace(host) == "" {
host = "local-machine"
}

View File

@@ -135,14 +135,6 @@ type daemonWorkspaceReposResponse struct {
ReposVersion string `json:"repos_version"`
}
// normalizeDaemonID strips the trailing `.local` mDNS suffix that macOS
// hostnames sometimes carry. The same normalization happens on the daemon
// side (see server/internal/daemon/config.go), but defending again here
// protects against pre-fix CLI versions and any non-CLI caller.
func normalizeDaemonID(id string) string {
return strings.TrimSuffix(id, ".local")
}
func normalizeWorkspaceRepos(repos []RepoData) []RepoData {
if len(repos) == 0 {
return []RepoData{}
@@ -209,7 +201,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
}
req.WorkspaceID = strings.TrimSpace(req.WorkspaceID)
req.DaemonID = normalizeDaemonID(strings.TrimSpace(req.DaemonID))
req.DaemonID = strings.TrimSpace(req.DaemonID)
req.DeviceName = strings.TrimSpace(req.DeviceName)
if req.DaemonID == "" {

View File

@@ -644,81 +644,3 @@ func TestGetDaemonWorkspaceRepos_VersionIgnoresOrderAndDescription(t *testing.T)
t.Fatalf("expected repos_version to change when URL set changes, got %s", version3)
}
}
func TestNormalizeDaemonID(t *testing.T) {
cases := []struct {
in, want string
}{
{"MacBook-Air.local", "MacBook-Air"},
{"MacBook-Air", "MacBook-Air"},
{"some.hostname.local", "some.hostname"},
{"local-machine", "local-machine"},
{".local", ""},
{"", ""},
}
for _, c := range cases {
if got := normalizeDaemonID(c.in); got != c.want {
t.Errorf("normalizeDaemonID(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestDaemonRegister_NormalizesLocalSuffix(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
const daemonRaw = "test-mac.local"
const daemonNorm = "test-mac"
// First register with the .local suffix — server should strip it before
// upserting, so the row lands under daemonNorm.
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
"workspace_id": testWorkspaceID,
"daemon_id": daemonRaw,
"device_name": "test-mac",
"runtimes": []map[string]any{
{"name": "claude-norm", "type": "claude", "version": "1.0.0", "status": "online"},
},
}, testWorkspaceID, daemonRaw)
testHandler.DaemonRegister(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first register: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
json.NewDecoder(w.Body).Decode(&resp)
firstID := resp["runtimes"].([]any)[0].(map[string]any)["id"].(string)
defer testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE daemon_id IN ($1, $2)`, daemonRaw, daemonNorm)
var stored string
if err := testPool.QueryRow(context.Background(),
`SELECT daemon_id FROM agent_runtime WHERE id = $1`, firstID).Scan(&stored); err != nil {
t.Fatalf("read first runtime row: %v", err)
}
if stored != daemonNorm {
t.Fatalf("first register: expected stored daemon_id %q, got %q", daemonNorm, stored)
}
// Second register with the canonical (no-suffix) form must hit the same
// row via the (workspace_id, daemon_id, provider) upsert key — proving
// the .local-form caller and the canonical-form caller share an identity.
w = httptest.NewRecorder()
req = newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
"workspace_id": testWorkspaceID,
"daemon_id": daemonNorm,
"device_name": "test-mac",
"runtimes": []map[string]any{
{"name": "claude-norm", "type": "claude", "version": "1.0.0", "status": "online"},
},
}, testWorkspaceID, daemonNorm)
testHandler.DaemonRegister(w, req)
if w.Code != http.StatusOK {
t.Fatalf("second register: expected 200, got %d: %s", w.Code, w.Body.String())
}
json.NewDecoder(w.Body).Decode(&resp)
secondID := resp["runtimes"].([]any)[0].(map[string]any)["id"].(string)
if secondID != firstID {
t.Fatalf("second register: expected same runtime id %q (upsert), got %q (insert) — .local suffix not normalized", firstID, secondID)
}
}

View File

@@ -1185,13 +1185,10 @@ func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bo
}
// shouldEnqueueOnComment returns true if a member comment on this issue should
// trigger the assigned agent. Fires for any non-terminal status — comments are
// conversational and can happen at any stage of active work.
// trigger the assigned agent. Fires for any status — comments are
// conversational and can happen at any stage, including after completion
// (e.g. follow-up questions on a done issue).
func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bool {
// Don't trigger on terminal statuses (done, cancelled).
if issue.Status == "done" || issue.Status == "cancelled" {
return false
}
if !h.isAgentAssigneeReady(ctx, issue) {
return false
}

View File

@@ -1,5 +0,0 @@
-- Cannot reliably restore the `.local` suffix: the migration discarded
-- which rows originally had it, and post-merge data may legitimately
-- belong to a daemon that always reported `X` (not `X.local`). The only
-- correct rollback path is to also revert PR #1070, after which existing
-- daemons will start re-registering under their original suffixed names.

View File

@@ -1,58 +0,0 @@
-- Normalize daemon_id by stripping the trailing `.local` mDNS suffix.
--
-- Daemons started via different methods on macOS used to register with
-- inconsistent hostnames: standalone CLI got `MacBook-Air` while the
-- desktop-bundled binary got `MacBook-Air.local` (or vice versa). PR #1070
-- (commit 6428a100) fixed the daemon side by stripping `.local` at hostname
-- resolution time, but did not address existing rows.
--
-- Without this migration, every macOS user upgrading past 6428a100 will
-- have all of their `agent_runtime` rows inserted again under the new
-- canonical `daemon_id`, leaving the old rows orphaned and the agents
-- (which reference `agent_runtime.id` via FK) pointing at runtimes that
-- no longer receive heartbeats.
--
-- Strategy:
-- 1. For every (workspace_id, provider) where both `X` and `X.local`
-- exist, keep `X` as the canonical row and redirect both
-- `agent.runtime_id` and `agent_task_queue.runtime_id` from the
-- `.local` row to the canonical row, then delete the duplicate.
-- 2. For any remaining rows that still end in `.local` (no canonical
-- counterpart), strip the suffix in place.
--
-- Note: `TRIM(TRAILING '.local' FROM ...)` is unsafe because TRIM treats
-- its argument as a character set, not a substring; we use a substring
-- expression on the LIKE-matched rows instead.
WITH pairs AS (
SELECT
canonical.id AS keep_id,
dot_local.id AS dup_id
FROM agent_runtime canonical
INNER JOIN agent_runtime dot_local
ON canonical.workspace_id = dot_local.workspace_id
AND canonical.provider = dot_local.provider
AND dot_local.daemon_id = canonical.daemon_id || '.local'
),
agent_redirect AS (
UPDATE agent
SET runtime_id = pairs.keep_id
FROM pairs
WHERE agent.runtime_id = pairs.dup_id
RETURNING agent.id
),
queue_redirect AS (
UPDATE agent_task_queue
SET runtime_id = pairs.keep_id
FROM pairs
WHERE agent_task_queue.runtime_id = pairs.dup_id
RETURNING agent_task_queue.id
)
DELETE FROM agent_runtime
WHERE id IN (SELECT dup_id FROM pairs);
UPDATE agent_runtime
SET
daemon_id = substring(daemon_id from 1 for length(daemon_id) - length('.local')),
updated_at = now()
WHERE daemon_id LIKE '%.local';

View File

@@ -10,7 +10,8 @@
"MULTICA_SERVER_URL",
"COMPOSE_PROJECT_NAME",
"POSTGRES_DB",
"POSTGRES_PORT"
"POSTGRES_PORT",
"DESKTOP_RENDERER_PORT"
],
"tasks": {
"build": {