mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/codebl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e3552c6b |
@@ -489,25 +489,6 @@ VITE_API_URL=http://localhost:<backend-port>
|
||||
VITE_WS_URL=ws://localhost:<backend-port>/ws
|
||||
```
|
||||
|
||||
#### Running multiple worktrees side-by-side
|
||||
|
||||
`pnpm dev:desktop` auto-isolates a worktree so several worktrees can run their
|
||||
own desktop dev instance at once — no extra setup. From a linked worktree it
|
||||
derives, from the worktree path (same `cksum % 1000` offset as the backend /
|
||||
frontend ports in `.env.worktree`):
|
||||
|
||||
- `DESKTOP_RENDERER_PORT` = `5174 + offset` — its own Vite dev server (`5174`
|
||||
base leaves `5173` for the primary checkout, even when `offset` is `0`)
|
||||
- `DESKTOP_APP_SUFFIX` = `<folder>-<offset>` — its own single-instance lock /
|
||||
`userData`, and an app named `Multica Canary <folder>-<offset>` so it is
|
||||
distinguishable in Cmd+Tab. The offset keeps it unique across worktrees that
|
||||
share a folder name at different paths.
|
||||
|
||||
The primary checkout is left untouched (`5173`, `Multica Canary`). Set either
|
||||
env var explicitly to override the derived value. Which backend each instance
|
||||
talks to is still controlled only by `apps/desktop/.env*` above — point each
|
||||
worktree's desktop at its own backend to also isolate the daemon profile.
|
||||
|
||||
### Isolation Guarantee
|
||||
|
||||
Nothing in this flow touches the system-installed `multica` or the default
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:staging": "node scripts/dev.mjs --mode staging",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
|
||||
"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",
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
// 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.
|
||||
//
|
||||
// In a worktree, scripts/dev.mjs sets DESKTOP_APP_SUFFIX so the name becomes
|
||||
// "Multica Canary <suffix>" — distinguishable in Cmd+Tab and matching the app
|
||||
// name src/main/index.ts derives from the same env var.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -21,9 +17,7 @@ import { resolve } from "node:path";
|
||||
|
||||
if (process.platform !== "darwin") process.exit(0);
|
||||
|
||||
const DESIRED_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
|
||||
: "Multica Canary";
|
||||
const DESIRED_NAME = "Multica Canary";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// `require('electron')` returns the path to the executable
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Dev launcher for `pnpm dev:desktop`.
|
||||
//
|
||||
// Derives per-worktree isolation env (renderer port + app name) so multiple
|
||||
// worktrees can run `pnpm dev:desktop` side-by-side, then runs the same chain
|
||||
// as before — bundle the CLI, brand the dev Electron, start electron-vite —
|
||||
// inheriting the augmented env. A plain `&&` chain in package.json can't do
|
||||
// this: each `&&` step is its own process, so an env tweak in step 1 wouldn't
|
||||
// reach electron-vite in step 3. Args (e.g. `--mode staging`) pass through to
|
||||
// electron-vite.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
applyWorktreeDevEnv,
|
||||
repoRootFromScriptDir,
|
||||
} from "./worktree-dev-env.mjs";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
applyWorktreeDevEnv(process.env, {
|
||||
root: repoRootFromScriptDir(here),
|
||||
log: true,
|
||||
});
|
||||
|
||||
function run(command, args, { shell = false } = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell,
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`[dev:desktop] failed to run ${command}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const node = process.execPath;
|
||||
run(node, [join(here, "bundle-cli.mjs")]);
|
||||
run(node, [join(here, "brand-dev-electron.mjs")]);
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const electronVite = join(
|
||||
here,
|
||||
"..",
|
||||
"node_modules",
|
||||
".bin",
|
||||
isWin ? "electron-vite.cmd" : "electron-vite",
|
||||
);
|
||||
run(electronVite, ["dev", ...process.argv.slice(2)], { shell: isWin });
|
||||
@@ -1,116 +0,0 @@
|
||||
// Per-worktree dev isolation for `pnpm dev:desktop`.
|
||||
//
|
||||
// Two `pnpm dev:desktop` instances from two different git worktrees collide on
|
||||
// the renderer Vite port (5173) and the single-instance lock / userData dir
|
||||
// (keyed by the app name "Multica Canary"). The env hooks to override both
|
||||
// already exist — electron.vite.config.ts reads DESKTOP_RENDERER_PORT and
|
||||
// src/main/index.ts reads DESKTOP_APP_SUFFIX — but nothing derives unique
|
||||
// values per worktree. This module does, mirroring the offset scheme that
|
||||
// scripts/init-worktree-env.sh already uses for backend/frontend ports.
|
||||
//
|
||||
// Backend targeting is deliberately NOT touched here: which backend the desktop
|
||||
// connects to stays driven by apps/desktop/.env* (VITE_API_URL / VITE_WS_URL),
|
||||
// exactly as documented. This module only adds the two knobs needed for two
|
||||
// Electron processes to coexist.
|
||||
|
||||
import { statSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
// Worktree renderer ports start at 5174 so they never reuse 5173 — the primary
|
||||
// checkout's default — even when a worktree's offset is 0 (e.g. POSIX cksum of
|
||||
// "/tmp/multica-3494" is 1189739000, and 1189739000 % 1000 === 0). Range 5174–6173.
|
||||
const RENDERER_PORT_BASE = 5174;
|
||||
const OFFSET_MODULO = 1000;
|
||||
|
||||
// POSIX cksum (CRC-32), kept byte-compatible with `cksum(1)` so the offset
|
||||
// matches scripts/init-worktree-env.sh — a worktree's backend (18080+offset),
|
||||
// frontend (13000+offset) and desktop renderer (5174+offset) ports all share
|
||||
// one offset. Verified against coreutils: cksum of "/tmp/foo" → 427878967.
|
||||
function cksumTable() {
|
||||
const table = new Uint32Array(256);
|
||||
const POLY = 0x04c11db7;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let crc = i << 24;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
crc = crc & 0x80000000 ? (crc << 1) ^ POLY : crc << 1;
|
||||
}
|
||||
table[i] = crc >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
const TABLE = cksumTable();
|
||||
|
||||
export function cksum(buf) {
|
||||
let crc = 0;
|
||||
for (const byte of buf) {
|
||||
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ byte) & 0xff]) >>> 0;
|
||||
}
|
||||
// POSIX appends the byte length, least-significant byte first.
|
||||
let len = buf.length;
|
||||
while (len > 0) {
|
||||
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ (len & 0xff)) & 0xff]) >>> 0;
|
||||
len = Math.floor(len / 256);
|
||||
}
|
||||
return (~crc) >>> 0;
|
||||
}
|
||||
|
||||
export function offsetForPath(path) {
|
||||
return cksum(Buffer.from(path)) % OFFSET_MODULO;
|
||||
}
|
||||
|
||||
export function rendererPortForPath(path) {
|
||||
return RENDERER_PORT_BASE + offsetForPath(path);
|
||||
}
|
||||
|
||||
// Worktree → a readable, unique, filesystem-safe suffix "<folder>-<offset>".
|
||||
// The dev app then shows e.g. "Multica Canary mul-3724-194" in Cmd+Tab and gets
|
||||
// its own userData / single-instance lock under that name. The offset is what
|
||||
// makes the lock unique: the folder name alone collides for worktrees that share
|
||||
// a basename at different paths (e.g. /a/multica vs /b/multica) or whose names
|
||||
// slug to the same fallback — those would share one lock and the second Electron
|
||||
// would still be blocked.
|
||||
export function appSuffixForPath(path) {
|
||||
const slug =
|
||||
basename(path)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "worktree";
|
||||
return `${slug}-${offsetForPath(path)}`;
|
||||
}
|
||||
|
||||
// A linked git worktree has a `.git` FILE (a "gitdir:" pointer); the primary
|
||||
// checkout has a `.git` DIRECTORY. We only auto-isolate linked worktrees, so
|
||||
// the primary checkout keeps the unchanged 5173 / "Multica Canary" defaults.
|
||||
export function isLinkedWorktree(root) {
|
||||
try {
|
||||
return statSync(join(root, ".git")).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// scripts live at <root>/apps/desktop/scripts
|
||||
export function repoRootFromScriptDir(scriptDir) {
|
||||
return join(scriptDir, "..", "..", "..");
|
||||
}
|
||||
|
||||
// Populate DESKTOP_RENDERER_PORT / DESKTOP_APP_SUFFIX on `env` for a worktree
|
||||
// checkout, without overriding values the caller set explicitly. Returns `env`.
|
||||
export function applyWorktreeDevEnv(env, { root, log = false } = {}) {
|
||||
const hasPort = Boolean(env.DESKTOP_RENDERER_PORT);
|
||||
const hasSuffix = Boolean(env.DESKTOP_APP_SUFFIX);
|
||||
if (hasPort && hasSuffix) return env; // explicit overrides win outright
|
||||
if (!isLinkedWorktree(root)) return env; // primary checkout → keep defaults
|
||||
|
||||
if (!hasPort) env.DESKTOP_RENDERER_PORT = String(rendererPortForPath(root));
|
||||
if (!hasSuffix) env.DESKTOP_APP_SUFFIX = appSuffixForPath(root);
|
||||
|
||||
if (log) {
|
||||
console.log(
|
||||
`[dev:desktop] worktree isolation → renderer port ${env.DESKTOP_RENDERER_PORT}, ` +
|
||||
`app "Multica Canary ${env.DESKTOP_APP_SUFFIX}"`,
|
||||
);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appSuffixForPath,
|
||||
applyWorktreeDevEnv,
|
||||
cksum,
|
||||
offsetForPath,
|
||||
rendererPortForPath,
|
||||
} from "./worktree-dev-env.mjs";
|
||||
|
||||
const cleanups = [];
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()();
|
||||
});
|
||||
|
||||
function tmpRoot(kind /* "file" | "dir" | "none" */) {
|
||||
const root = mkdtempSync(join(tmpdir(), "wt-"));
|
||||
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
if (kind === "file") writeFileSync(join(root, ".git"), "gitdir: /elsewhere\n");
|
||||
else if (kind === "dir") mkdirSync(join(root, ".git"));
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("worktree-dev-env", () => {
|
||||
it("cksum is byte-compatible with coreutils cksum(1)", () => {
|
||||
// `printf '%s' "/tmp/foo" | cksum` → 427878967 8
|
||||
expect(cksum(Buffer.from("/tmp/foo"))).toBe(427878967);
|
||||
// `printf '' | cksum` → 4294967295 0
|
||||
expect(cksum(Buffer.from(""))).toBe(4294967295);
|
||||
});
|
||||
|
||||
it("derives the offset from the path, mod 1000", () => {
|
||||
expect(offsetForPath("/tmp/foo")).toBe(427878967 % 1000);
|
||||
});
|
||||
|
||||
it("renderer port is 5174 + offset (5173 reserved for the primary checkout)", () => {
|
||||
expect(rendererPortForPath("/tmp/foo")).toBe(5174 + (427878967 % 1000));
|
||||
});
|
||||
|
||||
it("never reuses 5173 even when the offset is 0", () => {
|
||||
// POSIX cksum("/tmp/multica-3494") === 1189739000, % 1000 === 0
|
||||
expect(offsetForPath("/tmp/multica-3494")).toBe(0);
|
||||
expect(rendererPortForPath("/tmp/multica-3494")).toBe(5174);
|
||||
expect(rendererPortForPath("/tmp/multica-3494")).not.toBe(5173);
|
||||
});
|
||||
|
||||
it("suffix is '<folder>-<offset>' so it stays recognizable and unique", () => {
|
||||
expect(appSuffixForPath("/work/MUL-3724_Desktop")).toBe(
|
||||
`mul-3724-desktop-${offsetForPath("/work/MUL-3724_Desktop")}`,
|
||||
);
|
||||
expect(appSuffixForPath("/work/feat/some thing")).toBe(
|
||||
`some-thing-${offsetForPath("/work/feat/some thing")}`,
|
||||
);
|
||||
// empty/non-ascii slug falls back to "worktree", still disambiguated by offset
|
||||
expect(appSuffixForPath("/work/___")).toBe(`worktree-${offsetForPath("/work/___")}`);
|
||||
});
|
||||
|
||||
it("disambiguates worktrees that share a folder name at different paths", () => {
|
||||
// Same basename "multica", different parent dirs → different offsets/suffixes,
|
||||
// so each gets its own single-instance lock.
|
||||
expect(offsetForPath("/tmp/a/multica")).not.toBe(offsetForPath("/tmp/b/multica"));
|
||||
expect(appSuffixForPath("/tmp/a/multica")).not.toBe(
|
||||
appSuffixForPath("/tmp/b/multica"),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-isolates a linked worktree (.git is a file)", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = {};
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe(String(rendererPortForPath(root)));
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
|
||||
});
|
||||
|
||||
it("leaves the primary checkout untouched (.git is a dir)", () => {
|
||||
const root = tmpRoot("dir");
|
||||
const env = {};
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBeUndefined();
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects explicit env overrides", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = { DESKTOP_RENDERER_PORT: "9999", DESKTOP_APP_SUFFIX: "manual" };
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe("manual");
|
||||
});
|
||||
|
||||
it("fills only the missing knob when one is set explicitly", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = { DESKTOP_RENDERER_PORT: "9999" };
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
CreateRuntimeProfileRequest,
|
||||
UpdateRuntimeProfileRequest,
|
||||
InboxItem,
|
||||
InboxWorkspaceUnread,
|
||||
IssueSubscriber,
|
||||
Comment,
|
||||
CommentTriggerPreview,
|
||||
@@ -206,8 +205,6 @@ import {
|
||||
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
|
||||
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
|
||||
EMPTY_CANCEL_TASK_RESPONSE,
|
||||
InboxUnreadSummarySchema,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -1478,17 +1475,6 @@ export class ApiClient {
|
||||
return this.fetch("/api/inbox/unread-count");
|
||||
}
|
||||
|
||||
// Cross-workspace unread summary: one entry per workspace the user belongs
|
||||
// to that has unread inbox items. Backs the workspace-switcher dot for
|
||||
// OTHER workspaces. Schema-guarded so a contract drift hides the dot rather
|
||||
// than crashing the sidebar.
|
||||
async getInboxUnreadSummary(): Promise<InboxWorkspaceUnread[]> {
|
||||
const raw = await this.fetch<unknown>("/api/inbox/unread-summary");
|
||||
return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, {
|
||||
endpoint: "GET /api/inbox/unread-summary",
|
||||
});
|
||||
}
|
||||
|
||||
async markAllInboxRead(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
DuplicateIssueErrorBodySchema,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
EMPTY_USER,
|
||||
InboxUnreadSummarySchema,
|
||||
IssueTriggerPreviewSchema,
|
||||
ListIssuesResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
@@ -417,43 +415,3 @@ describe("AppConfigSchema cdn_signed drift", () => {
|
||||
expect(parsed.cdn_signed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxUnreadSummarySchema", () => {
|
||||
const ENDPOINT = { endpoint: "GET /api/inbox/unread-summary" };
|
||||
|
||||
it("parses a well-formed summary and tolerates extra fields", () => {
|
||||
const parsed = parseWithFallback(
|
||||
[
|
||||
{ workspace_id: "ws-1", count: 2 },
|
||||
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
|
||||
],
|
||||
InboxUnreadSummarySchema,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
ENDPOINT,
|
||||
);
|
||||
expect(parsed).toEqual([
|
||||
{ workspace_id: "ws-1", count: 2 },
|
||||
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the empty fallback (dot hidden) for a non-array body", () => {
|
||||
expect(
|
||||
parseWithFallback({ rows: [] }, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
|
||||
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
|
||||
expect(
|
||||
parseWithFallback(null, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
|
||||
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
|
||||
});
|
||||
|
||||
it("returns the empty fallback when an entry has a wrong-typed count", () => {
|
||||
expect(
|
||||
parseWithFallback(
|
||||
[{ workspace_id: "ws-1", count: "lots" }],
|
||||
InboxUnreadSummarySchema,
|
||||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||||
ENDPOINT,
|
||||
),
|
||||
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
CreateBillingCheckoutSessionResponse,
|
||||
CreateBillingPortalSessionResponse,
|
||||
GroupedIssuesResponse,
|
||||
InboxWorkspaceUnread,
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
SearchIssuesResponse,
|
||||
@@ -915,25 +914,6 @@ export const EMPTY_USER: User = {
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET).
|
||||
// One entry per workspace the user belongs to that has unread items; the
|
||||
// sidebar derives the workspace-switcher dot from it. Lenient per the usual
|
||||
// rules so a future field addition can't blank the dot — on malformed JSON
|
||||
// parseWithFallback returns the empty list, which simply hides the dot.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const InboxUnreadSummarySchema = z.array(
|
||||
z
|
||||
.object({
|
||||
workspace_id: z.string(),
|
||||
count: z.number(),
|
||||
})
|
||||
.loose(),
|
||||
);
|
||||
|
||||
export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Billing schemas (cloud-billing proxy surface)
|
||||
//
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InboxItem, InboxWorkspaceUnread } from "../types";
|
||||
import { deduplicateInboxItems, hasOtherWorkspaceUnread, inboxKeys, unreadWorkspaceIds } from "./queries";
|
||||
import type { InboxItem } from "../types";
|
||||
import { deduplicateInboxItems } from "./queries";
|
||||
|
||||
function item(overrides: Partial<InboxItem>): InboxItem {
|
||||
return {
|
||||
@@ -72,83 +72,3 @@ describe("deduplicateInboxItems", () => {
|
||||
expect(merged[0]?.details?.comment_id).toBe("comment-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasOtherWorkspaceUnread", () => {
|
||||
const summary = (entries: InboxWorkspaceUnread[]) => entries;
|
||||
|
||||
it("is true when a workspace other than the active one has unread", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([{ workspace_id: "ws-2", count: 3 }]),
|
||||
"ws-1",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes the active workspace's own unread", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([{ workspace_id: "ws-1", count: 5 }]),
|
||||
"ws-1",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores other workspaces whose count is zero", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([{ workspace_id: "ws-2", count: 0 }]),
|
||||
"ws-1",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when at least one non-active workspace has unread", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([
|
||||
{ workspace_id: "ws-1", count: 4 },
|
||||
{ workspace_id: "ws-2", count: 1 },
|
||||
]),
|
||||
"ws-1",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for an empty summary", () => {
|
||||
expect(hasOtherWorkspaceUnread([], "ws-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("counts every workspace as 'other' when there is no active workspace", () => {
|
||||
expect(
|
||||
hasOtherWorkspaceUnread(
|
||||
summary([{ workspace_id: "ws-1", count: 2 }]),
|
||||
null,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreadWorkspaceIds", () => {
|
||||
it("collects only workspaces with a non-zero count", () => {
|
||||
const ids = unreadWorkspaceIds([
|
||||
{ workspace_id: "ws-1", count: 0 },
|
||||
{ workspace_id: "ws-2", count: 3 },
|
||||
{ workspace_id: "ws-3", count: 1 },
|
||||
]);
|
||||
expect(ids.has("ws-1")).toBe(false);
|
||||
expect(ids.has("ws-2")).toBe(true);
|
||||
expect(ids.has("ws-3")).toBe(true);
|
||||
expect(ids.size).toBe(2);
|
||||
});
|
||||
|
||||
it("returns an empty set for an empty summary", () => {
|
||||
expect(unreadWorkspaceIds([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inboxKeys.unreadSummary", () => {
|
||||
it("is a stable account-level key independent of any workspace", () => {
|
||||
expect(inboxKeys.unreadSummary()).toEqual(["inbox", "unread-summary"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { queryOptions, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { InboxItem, InboxWorkspaceUnread } from "../types";
|
||||
import type { InboxItem } from "../types";
|
||||
|
||||
export const inboxKeys = {
|
||||
all: (wsId: string) => ["inbox", wsId] as const,
|
||||
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
|
||||
// Account-level (not workspace-scoped): a single shared cache entry that
|
||||
// holds unread counts for every workspace the user belongs to.
|
||||
unreadSummary: () => ["inbox", "unread-summary"] as const,
|
||||
};
|
||||
|
||||
export function inboxListOptions(wsId: string) {
|
||||
@@ -17,41 +14,6 @@ export function inboxListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-workspace unread inbox summary. One cache entry shared across all
|
||||
* workspaces — the data is account-level, so switching workspaces does not
|
||||
* refetch it; only the derived "is this for another workspace" view changes.
|
||||
*/
|
||||
export function inboxUnreadSummaryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: inboxKeys.unreadSummary(),
|
||||
queryFn: () => api.getInboxUnreadSummary(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any workspace OTHER than `currentWsId` has unread inbox items.
|
||||
* Drives the workspace-switcher dot: the active workspace's own unread is
|
||||
* already surfaced by the Inbox nav count, so it is excluded here to avoid a
|
||||
* duplicate signal.
|
||||
*/
|
||||
export function hasOtherWorkspaceUnread(
|
||||
summary: InboxWorkspaceUnread[],
|
||||
currentWsId: string | null | undefined,
|
||||
): boolean {
|
||||
return summary.some((s) => s.workspace_id !== currentWsId && s.count > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of workspace ids that have unread inbox items. Lets the workspace
|
||||
* switcher dropdown mark WHICH workspace a pending message lives in (the
|
||||
* aggregate switcher dot only says "somewhere else"). Workspaces with a zero
|
||||
* count are excluded.
|
||||
*/
|
||||
export function unreadWorkspaceIds(summary: InboxWorkspaceUnread[]): Set<string> {
|
||||
return new Set(summary.filter((s) => s.count > 0).map((s) => s.workspace_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread inbox count for the given workspace, aligned with what the inbox
|
||||
* list UI renders: archived items excluded, then deduplicated by issue so a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { onInboxIssueDeleted, onInboxIssueStatusChanged, onInboxSummaryInvalidate } from "./ws-updaters";
|
||||
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
|
||||
import { inboxKeys } from "./queries";
|
||||
import type { InboxItem } from "../types";
|
||||
|
||||
@@ -56,28 +56,6 @@ describe("onInboxIssueDeleted", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInboxSummaryInvalidate", () => {
|
||||
it("invalidates the account-level summary key regardless of active workspace", () => {
|
||||
const qc = new QueryClient();
|
||||
const spy = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
onInboxSummaryInvalidate(qc);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.unreadSummary() });
|
||||
});
|
||||
|
||||
it("does not disturb a workspace-scoped inbox list cache", () => {
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), [makeItem("i1", "issue-a")]);
|
||||
|
||||
onInboxSummaryInvalidate(qc);
|
||||
|
||||
// The list cache entry is untouched (different key); only the summary
|
||||
// query is marked stale.
|
||||
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))?.[0]?.id).toBe("i1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInboxIssueStatusChanged", () => {
|
||||
it("updates issue_status only for items referencing the issue", () => {
|
||||
const qc = new QueryClient();
|
||||
|
||||
@@ -41,12 +41,3 @@ export function onInboxIssueDeleted(
|
||||
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
}
|
||||
|
||||
// Refresh the cross-workspace unread summary (workspace-switcher dot). The
|
||||
// summary spans every workspace, so it is invalidated on ANY inbox event
|
||||
// regardless of which workspace the event came from — including read/archive
|
||||
// events from a workspace other than the active one, which the workspace-
|
||||
// scoped list invalidation cannot reach.
|
||||
export function onInboxSummaryInvalidate(qc: QueryClient) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ describe("useRealtimeSync — ws instance change", () => {
|
||||
|
||||
// Should have called invalidateQueries for all workspace-scoped keys
|
||||
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
|
||||
// + 1 cross-workspace inbox unread summary = 23 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(23);
|
||||
// = 22 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(22);
|
||||
});
|
||||
|
||||
it("does not re-invalidate when rerendered with the same ws instance", () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
onIssueLabelsChanged,
|
||||
onIssueMetadataChanged,
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxSummaryInvalidate } from "../inbox/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import {
|
||||
notificationPreferenceOptions,
|
||||
@@ -230,9 +230,6 @@ export async function handleInboxNew(
|
||||
): Promise<void> {
|
||||
const sourceWsId = item.workspace_id;
|
||||
if (sourceWsId) onInboxNew(qc, sourceWsId, item);
|
||||
// A new item in ANY workspace can light the workspace-switcher dot, so
|
||||
// refresh the cross-workspace summary regardless of the active workspace.
|
||||
onInboxSummaryInvalidate(qc);
|
||||
// Fire a native OS notification only when the app isn't focused. When
|
||||
// the user is already looking at Multica, the inbox sidebar's unread
|
||||
// styling is enough — no need to interrupt with a banner. `desktopAPI`
|
||||
@@ -323,9 +320,6 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
|
||||
}
|
||||
// Cross-workspace, so outside the wsId guard: a reconnect may have missed
|
||||
// inbox events from any workspace, so re-pull the switcher-dot summary.
|
||||
onInboxSummaryInvalidate(qc);
|
||||
// Per-issue caches are keyed without wsId, so the issueKeys.all(wsId)
|
||||
// prefix above does not reach them. They rely entirely on WS events for
|
||||
// freshness (staleTime: Infinity), so events missed while disconnected
|
||||
@@ -400,12 +394,6 @@ export function useRealtimeSync(
|
||||
inbox: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onInboxInvalidate(qc, wsId);
|
||||
// inbox:read / inbox:archived / batch events arrive here. They can
|
||||
// originate from a workspace other than the active one (personal
|
||||
// events fan out to all the user's connections), so always refresh
|
||||
// the cross-workspace summary — its dot must clear when another
|
||||
// workspace's items are read/archived.
|
||||
onInboxSummaryInvalidate(qc);
|
||||
},
|
||||
agent: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
|
||||
@@ -22,17 +22,6 @@ export type InboxItemType =
|
||||
| "quick_create_done"
|
||||
| "quick_create_failed";
|
||||
|
||||
/**
|
||||
* One workspace's unread inbox count in the cross-workspace summary
|
||||
* (`GET /api/inbox/unread-summary`). The sidebar uses this to light a dot on
|
||||
* the workspace switcher when a workspace OTHER than the active one has
|
||||
* unread items.
|
||||
*/
|
||||
export interface InboxWorkspaceUnread {
|
||||
workspace_id: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
|
||||
@@ -61,7 +61,7 @@ export type {
|
||||
} from "./agent";
|
||||
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
|
||||
export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment";
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
|
||||
@@ -425,21 +425,36 @@ export const ReadonlyContent = memo(function ReadonlyContent({
|
||||
// <Attachment>, which reads the surrounding AttachmentDownloadProvider.
|
||||
const components = useMemo(() => buildComponents(), []);
|
||||
|
||||
// Memoize the whole react-markdown subtree on its only real inputs
|
||||
// (`processed` + `components`). Unrelated parent re-renders (e.g. a sibling
|
||||
// agent task streaming over WebSocket fires one every ~100ms) would otherwise
|
||||
// re-run react-markdown, which hands `<code>` a fresh `dangerouslySetInnerHTML`
|
||||
// object each time; React then rewrites the highlighted innerHTML even though
|
||||
// the HTML string is byte-identical, tearing down and rebuilding every hljs
|
||||
// <span> — which collapses any active text selection inside a code block
|
||||
// (MUL-3621). A stable element reference lets React bail out of the subtree.
|
||||
const markdown = useMemo(
|
||||
() => (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
remarkBreaks,
|
||||
[remarkGfm, { singleTilde: false }],
|
||||
]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
[processed, components],
|
||||
);
|
||||
|
||||
return (
|
||||
<AttachmentDownloadProvider attachments={attachments}>
|
||||
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
remarkBreaks,
|
||||
[remarkGfm, { singleTilde: false }],
|
||||
]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
{markdown}
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
</AttachmentDownloadProvider>
|
||||
|
||||
@@ -3,14 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@multica/core/api";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
|
||||
const { detail, deletePin, navigation, pins, summary, workspaces } = vi.hoisted(() => ({
|
||||
const { detail, deletePin, navigation, pins } = vi.hoisted(() => ({
|
||||
detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } },
|
||||
deletePin: vi.fn(),
|
||||
navigation: { current: { pathname: "/acme/issues" } },
|
||||
summary: { current: [] as { workspace_id: string; count: number }[] },
|
||||
workspaces: {
|
||||
current: [] as { id: string; name: string; slug: string; avatar_url: string | null }[],
|
||||
},
|
||||
pins: {
|
||||
current: [
|
||||
{
|
||||
@@ -66,7 +62,7 @@ vi.mock("@multica/ui/components/ui/sidebar", () => ({
|
||||
}));
|
||||
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: () => null,
|
||||
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
@@ -126,17 +122,7 @@ vi.mock("@multica/core/api", async (importOriginal) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@multica/core/inbox/queries", () => ({
|
||||
deduplicateInboxItems: (items: unknown[]) => items,
|
||||
inboxKeys: { list: () => ["inbox"], unreadSummary: () => ["inbox", "unread-summary"] },
|
||||
inboxUnreadSummaryOptions: () => ({ queryKey: ["inbox", "unread-summary"] }),
|
||||
hasOtherWorkspaceUnread: (
|
||||
entries: { workspace_id: string; count: number }[],
|
||||
currentWsId: string | null,
|
||||
) => entries.some((s) => s.workspace_id !== currentWsId && s.count > 0),
|
||||
unreadWorkspaceIds: (entries: { workspace_id: string; count: number }[]) =>
|
||||
new Set(entries.filter((s) => s.count > 0).map((s) => s.workspace_id)),
|
||||
}));
|
||||
vi.mock("@multica/core/inbox/queries", () => ({ deduplicateInboxItems: (items: unknown[]) => items, inboxKeys: { list: () => ["inbox"] } }));
|
||||
vi.mock("@multica/core/issues/queries", () => ({ issueDetailOptions: () => ({ queryKey: ["issue"] }) }));
|
||||
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({
|
||||
useCreateModeStore: { getState: () => ({ lastMode: "agent" }) },
|
||||
@@ -159,8 +145,6 @@ vi.mock("@tanstack/react-query", async (importOriginal) => ({
|
||||
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
|
||||
if (queryKey[0] === "pins") return { data: pins.current };
|
||||
if (queryKey[0] === "issue") return detail.current;
|
||||
if (queryKey[0] === "inbox" && queryKey[1] === "unread-summary") return { data: summary.current };
|
||||
if (queryKey[0] === "workspaces") return { data: workspaces.current };
|
||||
return { data: [] };
|
||||
},
|
||||
useQueryClient: () => ({ fetchQuery: vi.fn(), invalidateQueries: vi.fn() }),
|
||||
@@ -171,8 +155,6 @@ describe("PinRow", () => {
|
||||
deletePin.mockReset();
|
||||
navigation.current.pathname = "/acme/issues";
|
||||
detail.current = { isPending: false, isError: false, data: null, error: null };
|
||||
summary.current = [];
|
||||
workspaces.current = [];
|
||||
});
|
||||
|
||||
it("unpins missing details", async () => {
|
||||
@@ -212,70 +194,3 @@ describe("PinRow", () => {
|
||||
expect(container.querySelector('button[data-href="/acme/issues"]')).not.toHaveAttribute("data-active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-switcher unread dot", () => {
|
||||
beforeEach(() => {
|
||||
summary.current = [];
|
||||
workspaces.current = [];
|
||||
});
|
||||
|
||||
// The aggregate switcher dot is the only `.ring-sidebar` span in the tree
|
||||
// (DraftDot is null when there's no draft, and there are no invitations).
|
||||
const dot = (container: HTMLElement) => container.querySelector("span.bg-brand.ring-sidebar");
|
||||
|
||||
it("shows a dot when another workspace has unread inbox items", () => {
|
||||
summary.current = [{ workspace_id: "ws-2", count: 3 }];
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(dot(container)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not show a dot when only the active workspace has unread", () => {
|
||||
// Active workspace is ws-1 (see useCurrentWorkspace mock).
|
||||
summary.current = [{ workspace_id: "ws-1", count: 3 }];
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(dot(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show a dot when no workspace has unread", () => {
|
||||
summary.current = [];
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(dot(container)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-switcher dropdown per-workspace dot", () => {
|
||||
beforeEach(() => {
|
||||
summary.current = [];
|
||||
// Active workspace is ws-1 (see useCurrentWorkspace mock); "Other" is ws-2.
|
||||
workspaces.current = [
|
||||
{ id: "ws-1", name: "Active WS", slug: "active", avatar_url: null },
|
||||
{ id: "ws-2", name: "Other WS", slug: "other", avatar_url: null },
|
||||
];
|
||||
});
|
||||
|
||||
// Row dots are brand dots WITHOUT the aggregate avatar dot's `ring-sidebar`.
|
||||
const rowDots = (container: HTMLElement) =>
|
||||
container.querySelectorAll("span.bg-brand:not(.ring-sidebar)");
|
||||
|
||||
it("dots the specific other workspace that has unread", () => {
|
||||
summary.current = [{ workspace_id: "ws-2", count: 3 }];
|
||||
const { container } = render(<AppSidebar />);
|
||||
// Exactly one row dot, sitting right after the "Other WS" name; the active
|
||||
// row shows the check, not a dot.
|
||||
expect(rowDots(container)).toHaveLength(1);
|
||||
expect(screen.getByText("Other WS").nextElementSibling?.className).toContain("bg-brand");
|
||||
expect(screen.getByText("Active WS").nextElementSibling?.className ?? "").not.toContain("bg-brand");
|
||||
});
|
||||
|
||||
it("does not dot a workspace whose unread count is zero", () => {
|
||||
summary.current = [{ workspace_id: "ws-2", count: 0 }];
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(rowDots(container)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never dots the active workspace even when it has unread", () => {
|
||||
summary.current = [{ workspace_id: "ws-1", count: 5 }];
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(rowDots(container)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/pat
|
||||
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { inboxKeys, deduplicateInboxItems, inboxUnreadSummaryOptions, hasOtherWorkspaceUnread, unreadWorkspaceIds } from "@multica/core/inbox/queries";
|
||||
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
@@ -101,7 +101,6 @@ const EMPTY_PINS: PinnedItem[] = [];
|
||||
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
|
||||
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
|
||||
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
|
||||
const EMPTY_INBOX_SUMMARY: Awaited<ReturnType<typeof api.getInboxUnreadSummary>> = [];
|
||||
|
||||
// Nav items reference WorkspacePaths method names so they can be resolved
|
||||
// against the current workspace slug at render time (see AppSidebar body).
|
||||
@@ -365,20 +364,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
|
||||
[inboxItems],
|
||||
);
|
||||
// Cross-workspace unread summary backs the workspace-switcher dot. One
|
||||
// shared cache entry across workspaces; gated on an active workspace since
|
||||
// the endpoint resolves through the workspace-member middleware.
|
||||
const { data: unreadSummary = EMPTY_INBOX_SUMMARY } = useQuery({
|
||||
...inboxUnreadSummaryOptions(),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const otherWorkspaceUnread = React.useMemo(
|
||||
() => hasOtherWorkspaceUnread(unreadSummary, wsId),
|
||||
[unreadSummary, wsId],
|
||||
);
|
||||
// Which workspaces have unread, so the switcher dropdown can point at the
|
||||
// specific one(s) rather than just the aggregate avatar dot.
|
||||
const unreadWsIds = React.useMemo(() => unreadWorkspaceIds(unreadSummary), [unreadSummary]);
|
||||
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
|
||||
const { data: pinnedItems = EMPTY_PINS } = useQuery({
|
||||
...pinListOptions(wsId ?? "", userId ?? ""),
|
||||
@@ -501,11 +486,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
<SidebarMenuButton>
|
||||
<span className="relative">
|
||||
<WorkspaceAvatar name={workspace?.name ?? "M"} avatarUrl={workspace?.avatar_url} size="sm" />
|
||||
{/* Shared brand dot: a pending invitation OR another
|
||||
workspace with unread inbox items. The active
|
||||
workspace's own unread stays on the Inbox nav count
|
||||
(below), so it is deliberately excluded here. */}
|
||||
{(myInvitations.length > 0 || otherWorkspaceUnread) && (
|
||||
{myInvitations.length > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-brand ring-1 ring-sidebar" />
|
||||
)}
|
||||
</span>
|
||||
@@ -552,14 +533,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
>
|
||||
<WorkspaceAvatar name={ws.name} avatarUrl={ws.avatar_url} size="sm" />
|
||||
<span className="flex-1 truncate">{ws.name}</span>
|
||||
{/* Points at the specific workspace holding unread
|
||||
inbox items. Sits in the same right-edge slot as the
|
||||
active-workspace check; the active workspace is
|
||||
excluded (its unread is the Inbox nav count), so dot
|
||||
and check never collide on one row. */}
|
||||
{ws.id !== workspace?.id && unreadWsIds.has(ws.id) && (
|
||||
<span className="size-2 rounded-full bg-brand" />
|
||||
)}
|
||||
{ws.id === workspace?.id && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
|
||||
@@ -793,127 +793,6 @@ func TestInboxThroughRouter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxUnreadSummaryThroughRouter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Seed one unread inbox item for the test user in the test workspace.
|
||||
var itemID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO inbox_item (workspace_id, recipient_type, recipient_id, type, title)
|
||||
VALUES ($1, 'member', $2, 'issue_assigned', 'Summary fixture')
|
||||
RETURNING id
|
||||
`, testWorkspaceID, testUserID).Scan(&itemID); err != nil {
|
||||
t.Fatalf("failed to seed inbox item: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM inbox_item WHERE id = $1`, itemID)
|
||||
})
|
||||
|
||||
resp := authRequest(t, "GET", "/api/inbox/unread-summary", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("UnreadInboxSummary: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var summary []struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
readJSON(t, resp, &summary)
|
||||
|
||||
var found bool
|
||||
for _, s := range summary {
|
||||
if s.WorkspaceID == testWorkspaceID {
|
||||
found = true
|
||||
if s.Count < 1 {
|
||||
t.Fatalf("expected unread count >= 1 for test workspace, got %d", s.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected test workspace %s in unread summary, got %+v", testWorkspaceID, summary)
|
||||
}
|
||||
|
||||
// After marking it read, the workspace should drop out of the summary.
|
||||
if _, err := testPool.Exec(ctx, `UPDATE inbox_item SET read = true WHERE id = $1`, itemID); err != nil {
|
||||
t.Fatalf("failed to mark item read: %v", err)
|
||||
}
|
||||
resp = authRequest(t, "GET", "/api/inbox/unread-summary", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("UnreadInboxSummary (after read): expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
readJSON(t, resp, &summary)
|
||||
for _, s := range summary {
|
||||
if s.WorkspaceID == testWorkspaceID && s.Count > 0 {
|
||||
t.Fatalf("expected no unread for test workspace after read, got count %d", s.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An issue's inbox notifications are deduplicated per issue: opening the issue
|
||||
// marks only the NEWEST item read, leaving older siblings unread. The summary
|
||||
// must mirror the inbox UI (issue is read when its newest item is read), so a
|
||||
// read-newest / unread-older issue must NOT light the switcher dot (MUL-3695).
|
||||
func TestInboxUnreadSummaryDedupesByIssue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var issueID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, title, creator_type, creator_id)
|
||||
VALUES ($1, 'Dedup fixture', 'member', $2)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
||||
t.Fatalf("failed to seed issue: %v", err)
|
||||
}
|
||||
// Deleting the issue cascades to its inbox_item rows (FK ON DELETE CASCADE).
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Older sibling stays unread; newer sibling is read (the one "opening the
|
||||
// issue" would have marked read).
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
INSERT INTO inbox_item (workspace_id, recipient_type, recipient_id, type, title, issue_id, read, created_at)
|
||||
VALUES
|
||||
($1, 'member', $2, 'new_comment', 'older', $3, false, now() - interval '1 hour'),
|
||||
($1, 'member', $2, 'status_changed', 'newer', $3, true, now())
|
||||
`, testWorkspaceID, testUserID, issueID); err != nil {
|
||||
t.Fatalf("failed to seed inbox items: %v", err)
|
||||
}
|
||||
|
||||
resp := authRequest(t, "GET", "/api/inbox/unread-summary", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("UnreadInboxSummary: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var summary []struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
readJSON(t, resp, &summary)
|
||||
for _, s := range summary {
|
||||
if s.WorkspaceID == testWorkspaceID && s.Count > 0 {
|
||||
t.Fatalf("issue whose newest item is read must not count as unread, got count %d", s.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// Now mark the newest item unread again → the issue becomes unread and the
|
||||
// workspace reappears in the summary.
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
UPDATE inbox_item SET read = false WHERE issue_id = $1 AND title = 'newer'
|
||||
`, issueID); err != nil {
|
||||
t.Fatalf("failed to flip newest item unread: %v", err)
|
||||
}
|
||||
resp = authRequest(t, "GET", "/api/inbox/unread-summary", nil)
|
||||
readJSON(t, resp, &summary)
|
||||
var found bool
|
||||
for _, s := range summary {
|
||||
if s.WorkspaceID == testWorkspaceID && s.Count >= 1 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected workspace in summary once newest item is unread, got %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 404 for non-existent resources ----
|
||||
|
||||
func TestNonExistentResources(t *testing.T) {
|
||||
|
||||
@@ -1066,9 +1066,6 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Route("/api/inbox", func(r chi.Router) {
|
||||
r.Get("/", h.ListInbox)
|
||||
r.Get("/unread-count", h.CountUnreadInbox)
|
||||
// Cross-workspace unread summary: account-level, keyed on the
|
||||
// user. Backs the workspace-switcher dot for OTHER workspaces.
|
||||
r.Get("/unread-summary", h.UnreadInboxSummary)
|
||||
r.Post("/mark-all-read", h.MarkAllInboxRead)
|
||||
r.Post("/archive-all", h.ArchiveAllInbox)
|
||||
r.Post("/archive-all-read", h.ArchiveAllReadInbox)
|
||||
|
||||
@@ -195,42 +195,6 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"count": count})
|
||||
}
|
||||
|
||||
// InboxWorkspaceUnreadResponse is one workspace's unread inbox count in the
|
||||
// cross-workspace summary.
|
||||
type InboxWorkspaceUnreadResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// UnreadInboxSummary returns per-workspace unread inbox counts across every
|
||||
// workspace the user belongs to. The sidebar uses it to light a dot on the
|
||||
// workspace switcher when a workspace OTHER than the active one has unread
|
||||
// items, without fetching each workspace's full inbox list. It is
|
||||
// account-level by nature: it ignores the active workspace and keys only on
|
||||
// the authenticated user.
|
||||
func (h *Handler) UnreadInboxSummary(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.Queries.CountUnreadInboxByWorkspace(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to summarize unread inbox")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]InboxWorkspaceUnreadResponse, len(rows))
|
||||
for i, row := range rows {
|
||||
resp[i] = InboxWorkspaceUnreadResponse{
|
||||
WorkspaceID: uuidToString(row.WorkspaceID),
|
||||
Count: row.Count,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -175,57 +175,6 @@ func (q *Queries) CountUnreadInbox(ctx context.Context, arg CountUnreadInboxPara
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countUnreadInboxByWorkspace = `-- name: CountUnreadInboxByWorkspace :many
|
||||
SELECT newest.workspace_id, count(*) AS count
|
||||
FROM (
|
||||
SELECT DISTINCT ON (i.workspace_id, COALESCE(i.issue_id, i.id))
|
||||
i.workspace_id, i.read
|
||||
FROM inbox_item i
|
||||
JOIN member m ON m.workspace_id = i.workspace_id AND m.user_id = i.recipient_id
|
||||
WHERE i.recipient_type = 'member'
|
||||
AND i.recipient_id = $1
|
||||
AND i.archived = false
|
||||
ORDER BY i.workspace_id, COALESCE(i.issue_id, i.id), i.created_at DESC
|
||||
) newest
|
||||
WHERE newest.read = false
|
||||
GROUP BY newest.workspace_id
|
||||
`
|
||||
|
||||
type CountUnreadInboxByWorkspaceRow struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// Per-workspace unread inbox counts for a recipient member, matching the
|
||||
// inbox UI's deduplicated view: notifications are grouped per issue
|
||||
// (Linear-style, one row per issue) and an issue counts as unread only when
|
||||
// its NEWEST non-archived item is unread. Opening an issue marks just that
|
||||
// newest item read, so counting raw unread rows would keep older siblings
|
||||
// alive and light the switcher dot for a workspace whose inbox the user sees
|
||||
// as empty (MUL-3695). Items without an issue group on their own id. The
|
||||
// member join keeps counts scoped to workspaces the user still belongs to,
|
||||
// so a stale item left behind in a workspace the user has since left cannot
|
||||
// light the dot.
|
||||
func (q *Queries) CountUnreadInboxByWorkspace(ctx context.Context, recipientID pgtype.UUID) ([]CountUnreadInboxByWorkspaceRow, error) {
|
||||
rows, err := q.db.Query(ctx, countUnreadInboxByWorkspace, recipientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []CountUnreadInboxByWorkspaceRow{}
|
||||
for rows.Next() {
|
||||
var i CountUnreadInboxByWorkspaceRow
|
||||
if err := rows.Scan(&i.WorkspaceID, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const createInboxItem = `-- name: CreateInboxItem :one
|
||||
INSERT INTO inbox_item (
|
||||
workspace_id, recipient_type, recipient_id,
|
||||
|
||||
@@ -45,31 +45,6 @@ RETURNING recipient_type, recipient_id;
|
||||
SELECT count(*) FROM inbox_item
|
||||
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false;
|
||||
|
||||
-- name: CountUnreadInboxByWorkspace :many
|
||||
-- Per-workspace unread inbox counts for a recipient member, matching the
|
||||
-- inbox UI's deduplicated view: notifications are grouped per issue
|
||||
-- (Linear-style, one row per issue) and an issue counts as unread only when
|
||||
-- its NEWEST non-archived item is unread. Opening an issue marks just that
|
||||
-- newest item read, so counting raw unread rows would keep older siblings
|
||||
-- alive and light the switcher dot for a workspace whose inbox the user sees
|
||||
-- as empty (MUL-3695). Items without an issue group on their own id. The
|
||||
-- member join keeps counts scoped to workspaces the user still belongs to,
|
||||
-- so a stale item left behind in a workspace the user has since left cannot
|
||||
-- light the dot.
|
||||
SELECT newest.workspace_id, count(*) AS count
|
||||
FROM (
|
||||
SELECT DISTINCT ON (i.workspace_id, COALESCE(i.issue_id, i.id))
|
||||
i.workspace_id, i.read
|
||||
FROM inbox_item i
|
||||
JOIN member m ON m.workspace_id = i.workspace_id AND m.user_id = i.recipient_id
|
||||
WHERE i.recipient_type = 'member'
|
||||
AND i.recipient_id = $1
|
||||
AND i.archived = false
|
||||
ORDER BY i.workspace_id, COALESCE(i.issue_id, i.id), i.created_at DESC
|
||||
) newest
|
||||
WHERE newest.read = false
|
||||
GROUP BY newest.workspace_id;
|
||||
|
||||
-- name: MarkAllInboxRead :execrows
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false;
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"COMPOSE_PROJECT_NAME",
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_PORT",
|
||||
"DESKTOP_RENDERER_PORT",
|
||||
"DESKTOP_APP_SUFFIX"
|
||||
"DESKTOP_RENDERER_PORT"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user