mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 22:17:11 +02:00
Compare commits
8 Commits
v0.2.1
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f56b4503 | ||
|
|
8816e1669c | ||
|
|
209300c86f | ||
|
|
3d98f64ea1 | ||
|
|
ec30e46947 | ||
|
|
6428a10046 | ||
|
|
fe6208c61f | ||
|
|
336f90fd26 |
@@ -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()],
|
||||
|
||||
@@ -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",
|
||||
|
||||
73
apps/desktop/scripts/brand-dev-electron.mjs
Normal file
73
apps/desktop/scripts/brand-dev-electron.mjs
Normal 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}"`);
|
||||
@@ -39,6 +39,18 @@ function sh(cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the leading `--` that npm/pnpm insert to separate their own
|
||||
* flags from the ones meant for the underlying script. Without this,
|
||||
* `pnpm package -- --mac --arm64 --publish always` forwards the bare
|
||||
* `--` into electron-builder's argv, which terminates option parsing
|
||||
* and turns `--publish always` into ignored positional arguments.
|
||||
*/
|
||||
export function stripLeadingSeparator(argv) {
|
||||
if (argv.length > 0 && argv[0] === "--") return argv.slice(1);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transformation from the `git describe --tags --always --dirty`
|
||||
* output to the value we feed into electron-builder's extraMetadata.version.
|
||||
@@ -102,7 +114,7 @@ function main() {
|
||||
}
|
||||
|
||||
// Step 4: assemble electron-builder args.
|
||||
const passthrough = process.argv.slice(2);
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeGitVersion } from "./package.mjs";
|
||||
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
@@ -37,3 +37,25 @@ describe("normalizeGitVersion", () => {
|
||||
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLeadingSeparator", () => {
|
||||
it("removes the leading -- inserted by npm/pnpm", () => {
|
||||
expect(stripLeadingSeparator(["--", "--mac", "--arm64", "--publish", "always"])).toEqual([
|
||||
"--mac", "--arm64", "--publish", "always",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves args untouched when there is no leading --", () => {
|
||||
expect(stripLeadingSeparator(["--mac", "--arm64"])).toEqual(["--mac", "--arm64"]);
|
||||
});
|
||||
|
||||
it("does not strip a -- that appears mid-argv", () => {
|
||||
expect(stripLeadingSeparator(["--mac", "--", "--arm64"])).toEqual([
|
||||
"--mac", "--", "--arm64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an empty array", () => {
|
||||
expect(stripLeadingSeparator([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -87,14 +87,18 @@ function AppContent() {
|
||||
// Tabs survive across app restarts and account switches (persisted to
|
||||
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
|
||||
// reference a workspace the current user can't access — showing
|
||||
// NoAccessPage every time they open the app. Reset any such tab to `/`
|
||||
// so IndexRedirect picks a valid workspace. Runs on every workspace list
|
||||
// change (login, refetch, realtime workspace:deleted); idempotent.
|
||||
useEffect(() => {
|
||||
if (!workspaces) return;
|
||||
// NoAccessPage every time they open the app.
|
||||
//
|
||||
// Run synchronously in render phase rather than in useEffect so the first
|
||||
// render already sees validated tabs. useEffect runs AFTER commit, which
|
||||
// means the initial render would briefly show NoAccessPage before the
|
||||
// effect resets the tab. Zustand supports render-phase setState; the
|
||||
// validator is idempotent (exits early if nothing changed) so this
|
||||
// doesn't loop.
|
||||
if (workspaces) {
|
||||
const validSlugs = new Set(workspaces.map((w) => w.slug));
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
}, [workspaces]);
|
||||
}
|
||||
// null = undecided (pre-login or list hasn't settled yet)
|
||||
// true = session started with zero workspaces; next transition to >=1 triggers restart
|
||||
// false = session started with >=1 workspace, OR we've already restarted; skip
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { isGlobalPath, paths } from "@multica/core/paths";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Inbox,
|
||||
@@ -124,10 +125,22 @@ function NewTabButton() {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
|
||||
const handleClick = () => {
|
||||
const path = "/issues";
|
||||
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
|
||||
// opens in the same context as the active one. Read the slug from the
|
||||
// active tab's path directly rather than from getCurrentSlug(), because
|
||||
// that singleton is "last tab to render" (non-deterministic with N tabs
|
||||
// mounted under <Activity>), while activeTabId is the unambiguous truth.
|
||||
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
|
||||
// tab is on a global route (e.g. /workspaces/new, /login).
|
||||
const { tabs, activeTabId } = useTabStore.getState();
|
||||
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
|
||||
let slug: string | null = null;
|
||||
if (activePath !== "/" && !isGlobalPath(activePath)) {
|
||||
slug = activePath.split("/").filter(Boolean)[0] ?? null;
|
||||
}
|
||||
const path = slug ? paths.workspace(slug).issues() : "/";
|
||||
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
|
||||
setActiveTab(tabId);
|
||||
// No navigate() — new tab's router starts at /issues automatically
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
45
apps/desktop/src/renderer/src/stores/tab-store.test.ts
Normal file
45
apps/desktop/src/renderer/src/stores/tab-store.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// createTabRouter transitively pulls in route modules that expect a browser
|
||||
// router context. For pure-function tests we stub it out.
|
||||
vi.mock("../routes", () => ({
|
||||
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { sanitizeTabPath } from "./tab-store";
|
||||
|
||||
describe("sanitizeTabPath", () => {
|
||||
it("passes through root sentinel", () => {
|
||||
expect(sanitizeTabPath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("passes through global paths", () => {
|
||||
expect(sanitizeTabPath("/login")).toBe("/login");
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
|
||||
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
|
||||
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
|
||||
});
|
||||
|
||||
it("passes through valid workspace-scoped paths", () => {
|
||||
expect(sanitizeTabPath("/acme/issues")).toBe("/acme/issues");
|
||||
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
|
||||
});
|
||||
|
||||
it("rejects paths whose first segment is a reserved slug", () => {
|
||||
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
|
||||
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(sanitizeTabPath("/issues")).toBe("/");
|
||||
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
|
||||
expect(sanitizeTabPath("/settings")).toBe("/");
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
|
||||
// A workspace owner could legitimately pick "acme-issues" or
|
||||
// "project-x" as their slug — sanitize must not touch these.
|
||||
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
|
||||
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import { isGlobalPath } from "@multica/core/paths";
|
||||
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -104,13 +104,44 @@ function createId(): string {
|
||||
return createSafeId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive: catch tab paths that were constructed without a workspace slug
|
||||
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
|
||||
* paths would get matched as `workspaceSlug="issues"` by the router and
|
||||
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
|
||||
* a valid workspace).
|
||||
*
|
||||
* Passes through:
|
||||
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
|
||||
* - workspace-scoped paths whose first segment is not a reserved word
|
||||
*
|
||||
* Rejects (and rewrites to "/"):
|
||||
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
|
||||
* means the caller forgot to prefix the workspace. Logs a warning so the
|
||||
* buggy call site is easy to find.
|
||||
*/
|
||||
export function sanitizeTabPath(path: string): string {
|
||||
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
|
||||
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (isReservedSlug(firstSegment)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Falling back to "/".`,
|
||||
);
|
||||
return DEFAULT_PATH;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
const safePath = sanitizeTabPath(path);
|
||||
return {
|
||||
id: createId(),
|
||||
path,
|
||||
path: safePath,
|
||||
title,
|
||||
icon,
|
||||
router: createTabRouter(path),
|
||||
router: createTabRouter(safePath),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
@@ -239,19 +270,13 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!persisted?.tabs?.length) return currentState;
|
||||
|
||||
const tabs: Tab[] = persisted.tabs.map((tab) => {
|
||||
// Migration: pre-refactor tab paths like "/issues/abc" lack a
|
||||
// workspace slug prefix. These would 404 in the new router.
|
||||
// Reset to "/" so IndexRedirect picks the right workspace.
|
||||
let path = tab.path;
|
||||
if (path !== "/" && !isGlobalPath(path)) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const firstSegment = segments[0] ?? "";
|
||||
// If the first segment IS a known route name (e.g. "issues",
|
||||
// "projects"), it's an old-format path missing the slug prefix.
|
||||
if (ROUTE_ICONS[firstSegment]) {
|
||||
path = "/";
|
||||
}
|
||||
}
|
||||
// Sanitize persisted paths against reserved-slug rules. Catches
|
||||
// both pre-refactor paths like "/issues/abc" (missing workspace
|
||||
// slug) and any other malformed paths that slipped past the
|
||||
// write-time guard. The defense across makeTab + merge + runtime
|
||||
// validate ensures stale or malformed paths never reach the
|
||||
// router.
|
||||
const path = sanitizeTabPath(tab.path);
|
||||
return {
|
||||
...tab,
|
||||
path,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useRecentIssuesStore } from "./stores";
|
||||
import type { Issue, IssueReaction } from "../types";
|
||||
import type {
|
||||
CreateIssueRequest,
|
||||
@@ -94,6 +95,9 @@ export function useCreateIssue() {
|
||||
}
|
||||
: old,
|
||||
);
|
||||
// Surface the just-created issue in cmd+k's Recent list without
|
||||
// requiring the user to open it first.
|
||||
useRecentIssuesStore.getState().recordVisit(newIssue.id);
|
||||
// Invalidate parent's children query so sub-issues list updates immediately
|
||||
if (newIssue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
|
||||
|
||||
46
packages/core/issues/stores/comment-collapse-store.ts
Normal file
46
packages/core/issues/stores/comment-collapse-store.ts
Normal 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());
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"MULTICA_SERVER_URL",
|
||||
"COMPOSE_PROJECT_NAME",
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_PORT"
|
||||
"POSTGRES_PORT",
|
||||
"DESKTOP_RENDERER_PORT"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user