Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ The preload script exposes a typed `window.vellum` API to the renderer:
[`apps/web/src/runtime/vellum-commands.ts`](../web/src/runtime/vellum-commands.ts);
feature code mounts the `useVellumCommands` hook with a partial handler
map at whichever component owns the relevant state.
- `dock.setBadge(count)` / `dock.setSignedIn(signedIn)` — publish the
inputs that drive the Dock unread badge and visibility state machine
in [`src/main/dock.ts`](src/main/dock.ts). Renderer wrapper at
[`apps/web/src/runtime/dock.ts`](../web/src/runtime/dock.ts) (no-ops
off Electron); feature code calls
`useElectronDockSync(conversations)` from `ChatLayout` to keep them in
sync. The accessory-mode (Dock-hidden) transition is gated on
`ALLOW_ACCESSORY_MODE` until a menu-bar (tray) entry point exists;
until then the icon stays in the Dock so the user always has a way
back to the window.
- `auth.*` and `helper.*` — typed stubs that reject with "not implemented yet"
until the corresponding feature tickets land.

Expand Down
170 changes: 170 additions & 0 deletions apps/macos/src/main/dock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { app, BrowserWindow, ipcMain } from "electron";

/**
* Dock integration: unread-count badge + visibility state machine.
*
* Mirrors what the Swift app does today (`AppDelegate+WindowsAndSurfaces.swift`
* → `NSApp.dockTile.badgeLabel`, `NSApplication.ActivationPolicy.regular`
* ⇄ `.accessory`) so users see no regression when they cut over to
* Electron.
*
* The state machine has two inputs:
*
* 1. **Visible window count**, observed via the `browser-window-created`
* / per-window `closed` events. No renderer involvement.
* 2. **Signed-in flag**, published by the renderer over the
* `vellum:dock:setSignedIn` IPC channel. Renderer is the source of
* truth today; this side of the bridge becomes a no-op once the
* main-process auth state is the canonical signal.
*
* Policy:
*
* - Any visible window OR signed in → `regular` (Dock icon visible).
* We keep the icon visible while signed in so the user can re-open
* the window from the Dock after closing the last one.
* - No visible window AND signed out → `accessory` (Dock icon hidden,
* menu-bar-only).
*
* Transitions are debounced ~100ms so a fast close-then-open (e.g.
* keyboard shortcut chord) doesn't visibly flash the Dock icon.
*/

// Format the badge string per macOS Dock conventions: "" clears,
// "1"–"99" pass through, anything beyond becomes "99+" (the Slack-style
// truncation Swift Vellum already uses — `\"99+\"` shows up at
// `clients/macos/.../AppDelegate+WindowsAndSurfaces.swift:660-691`).
//
// `> 999 → "999+"` is what we'd want if we ever exposed a triple-digit
// counter, but macOS truncates very long strings and Swift caps at 99
// today; we match Swift.
const formatBadge = (count: number): string => {
if (!Number.isFinite(count) || count <= 0) return "";
if (count > 99) return "99+";
return String(Math.floor(count));
};

const POLICY_DEBOUNCE_MS = 100;

// Gate the `accessory` (menu-bar-only) transition until a menu-bar
// (tray) entry point exists. Going accessory before then would hide
// the Dock icon with no replacement, leaving the user no way to re-open
// the window. Flip to `true` in the same change that lands the tray.
const ALLOW_ACCESSORY_MODE = false;

interface DockState {
signedIn: boolean;
badgeCount: number;
policy: "regular" | "accessory";
}

const state: DockState = {
signedIn: false,
badgeCount: 0,
policy: "regular",
};

let refreshTimer: NodeJS.Timeout | null = null;

const visibleWindowCount = (): number =>
BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed()).length;

const computePolicy = (): DockState["policy"] => {
if (visibleWindowCount() > 0) return "regular";
if (state.signedIn) return "regular";
return ALLOW_ACCESSORY_MODE ? "accessory" : "regular";
};

// `app.dock.show()` returns a Promise that resolves once the Dock has
// reflected the change; `setActivationPolicy("regular")` after it
// keeps the two surfaces in sync (await sequencing is the documented
// pattern). The accessory transition is synchronous on the Electron
// side — `hide()` returns void — so no await there.
const applyPolicy = async (next: DockState["policy"]): Promise<void> => {
if (next === state.policy) return;
state.policy = next;
if (!app.dock) return;
if (next === "regular") {
await app.dock.show();
app.setActivationPolicy("regular");
} else {
app.dock.hide();
app.setActivationPolicy("accessory");
}
};

const scheduleRefresh = (): void => {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
refreshTimer = null;
void applyPolicy(computePolicy());
}, POLICY_DEBOUNCE_MS);
};

const applyBadge = (): void => {
if (!app.dock) return;
app.dock.setBadge(formatBadge(state.badgeCount));
};

/**
* Wire the dock state machine. Call once from `whenReady`. Idempotent
* — repeated calls are no-ops, so it's safe under hot-reload of the
* main bundle in dev.
*/
let installed = false;
export const installDock = (): void => {
if (installed) return;
installed = true;

// Renderer publishes the unread count whenever it changes. Coerce to
// a finite non-negative integer so a renderer bug can't crash main.
ipcMain.handle("vellum:dock:setBadge", (_event, count: unknown) => {
const n = typeof count === "number" && Number.isFinite(count) ? count : 0;
state.badgeCount = Math.max(0, Math.floor(n));
applyBadge();
});

// Renderer-published signed-in flag. Becomes redundant once main
// owns the auth state directly — at that point the source of truth
// flips and this handler can be replaced with a subscription.
//
// On a flip to signed-out we also clear the badge synchronously
// (here, ahead of the debounced policy refresh). Otherwise a logout
// that destroys the renderer's JS context (hard navigate) can leave
// a stale count on the Dock — the renderer never gets to publish
// `setDockBadge(0)` because the layout unmounts first.
ipcMain.handle("vellum:dock:setSignedIn", (_event, signedIn: unknown) => {
const next = Boolean(signedIn);
if (state.signedIn && !next) {
state.badgeCount = 0;
applyBadge();
}
state.signedIn = next;
scheduleRefresh();
});

// Observe the visible-window count so closing the last window can
// transition us into accessory mode (once `ALLOW_ACCESSORY_MODE` is
// flipped), and opening the first one transitions us back to regular.
app.on("browser-window-created", (_event, win) => {
scheduleRefresh();
win.once("closed", scheduleRefresh);
});

// macOS convention: clear the Dock badge before the process exits so
// a relaunch doesn't briefly show a stale count from the OS's cache.
app.on("before-quit", () => {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
if (app.dock) app.dock.setBadge("");
});

// Apply the initial policy + (empty) badge so we don't briefly show
// the wrong state before the first event fires. The policy update is
// fire-and-forget — its `dock.show()` Promise just sequences the
// following `setActivationPolicy` call inside `applyPolicy`; the
// caller has nothing to await on.
void applyPolicy(computePolicy());
applyBadge();
};
2 changes: 2 additions & 0 deletions apps/macos/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import path from "node:path";
import { installApplicationMenu } from "./menu";
import { readSetting, writeSetting } from "./settings";
import { restoreBounds, track as trackWindowState } from "./window-state";
import { installDock } from "./dock";

// Dev-mode renderer URL. Honors `VELLUM_DEV_URL` so the launcher can
// point the BrowserWindow at whichever Vite-or-equivalent is actually
Expand Down Expand Up @@ -330,6 +331,7 @@ app
installPermissionHandler();
installSettingsIpc();
installApplicationMenu();
installDock();
spawnDaemon();
createWindow();

Expand Down
22 changes: 22 additions & 0 deletions apps/macos/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ export interface VellumBridge {
*/
on(callback: (command: VellumCommand) => void): () => void;
};
dock: {
/**
* Publish the unread count so the main process can update the macOS
* Dock badge. Pass `0` (or any non-positive number) to clear. Main
* formats per Swift Vellum's convention — pass-through up to 99,
* `"99+"` beyond.
*/
setBadge(count: number): Promise<void>;
/**
* Publish the user's signed-in state so the main process can decide
* whether to keep the Dock icon visible after the last window
* closes. Temporary — once main owns auth state directly, this
* call becomes a no-op and the renderer drops it.
*/
setSignedIn(signedIn: boolean): Promise<void>;
};
}

const notImplemented = (name: string) => (): Promise<never> =>
Expand Down Expand Up @@ -73,6 +89,12 @@ const bridge: VellumBridge = {
};
},
},
dock: {
setBadge: (count: number): Promise<void> =>
ipcRenderer.invoke("vellum:dock:setBadge", count) as Promise<void>,
setSignedIn: (signedIn: boolean): Promise<void> =>
ipcRenderer.invoke("vellum:dock:setSignedIn", signedIn) as Promise<void>,
},
};

contextBridge.exposeInMainWorld("vellum", bridge);
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/domains/chat/chat-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useAssistantIdentityInit } from "@/hooks/use-assistant-identity-init";
import { useAssistantAvatar } from "@/hooks/use-assistant-avatar";
import { useDynamicFavicon } from "@/hooks/use-dynamic-favicon";
import { useHomeUnreadBadge } from "@/hooks/use-home-unread-badge";
import { useElectronDockSync } from "@/domains/chat/hooks/use-electron-dock-sync";
import type { AssistantContextValue } from "@/components/layout/assistant-context";

import { useVellumCommands } from "@/runtime/vellum-commands";
Expand Down Expand Up @@ -216,6 +217,12 @@ export function ChatLayout() {
homePageEnabled ? lifecycle.assistantId : null,
);

// Mirror the unread count + signed-in flag into the Electron Dock
// (no-op off Electron). Uses the conversation list this layout
// already subscribes to, so there's no extra query — see
// `./hooks/use-electron-dock-sync.ts`.
useElectronDockSync(conversations);

// --- Layout slot state for child route content ---
const [topBarCenter, setTopBarCenter] = useState<ReactNode>(null);
const [topBarRightSlot, setTopBarRightSlot] = useState<ReactNode>(null);
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/domains/chat/hooks/use-electron-dock-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useMemo } from "react";

import { setDockBadge, setDockSignedIn } from "@/runtime/dock";
import { useAuthStore } from "@/stores/auth-store";
import type { Conversation } from "@/types/conversation-types";
import { contributesToUnreadCount } from "@/utils/conversation-predicates";

/**
* Publish the data the Electron Dock cares about — unread conversation
* count and signed-in state — to the main process via the
* `window.vellum.dock.*` bridge. Both wrappers no-op on non-Electron
* hosts (see `@/runtime/dock`), so this hook is safe to mount
* unconditionally inside `ChatLayout`.
*
* Mount the hook once at a layout that already has the conversation
* list in hand (currently `ChatLayout`, which subscribes to
* `useConversationListQuery` at the route root). The count is derived
* locally via `contributesToUnreadCount` (the same predicate that
* drives sidebar attention indicators) so we don't fetch twice and
* automated background / scheduled / archived threads don't contribute
* to the badge.
*
* The signed-in input is temporary: once main owns auth state
* directly, main becomes the source of truth and this side of the
* bridge becomes a no-op. (Main also clears the badge when signed-in
* flips to false so a logout-driven remount of this layout can't leave
* a stale count on the Dock.)
*/
export function useElectronDockSync(conversations: Conversation[]): void {
const isLoggedIn = useAuthStore.use.isLoggedIn();

const unreadCount = useMemo(
() =>
conversations.reduce(
(n, c) => (contributesToUnreadCount(c) ? n + 1 : n),
0,
),
[conversations],
);

useEffect(() => {
void setDockBadge(unreadCount);
}, [unreadCount]);

useEffect(() => {
void setDockSignedIn(isLoggedIn);
Comment thread
ashleeradka marked this conversation as resolved.
}, [isLoggedIn]);
}
39 changes: 39 additions & 0 deletions apps/web/src/runtime/dock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { isElectron } from "@/runtime/is-electron";

/**
* Per-capability wrapper for the Electron host's Dock integration. Matches
* the pattern in `native-biometric.ts`: the renderer never touches
* `window.vellum.*` directly — feature code calls these named functions
* and the cross-platform branch lives here.
*
* On non-Electron hosts (web, Capacitor iOS) both functions are no-ops
* that resolve immediately, so callers can fire them unconditionally on
* state change without an `isElectron()` check at every call site.
*/

/**
* Publish the unread conversation count to the Electron Dock badge.
* Main formats per Swift Vellum's convention (1–99 pass through, 99+
* truncates). Pass `0` to clear.
*
* Safe to call from any host — no-op off Electron.
*/
export async function setDockBadge(count: number): Promise<void> {
if (!isElectron()) return;
await window.vellum?.dock.setBadge(count);
}

/**
* Publish the user's signed-in state. The main process uses this to
* decide whether to keep the Dock icon visible after the last window
* closes (so the user can re-open from the Dock vs. having to relaunch
* from /Applications).
*
* Temporary — once main owns auth state directly, main becomes the
* source of truth and this becomes a no-op the renderer drops. Safe to
* call from any host — no-op off Electron.
*/
export async function setDockSignedIn(signedIn: boolean): Promise<void> {
if (!isElectron()) return;
await window.vellum?.dock.setSignedIn(signedIn);
}
4 changes: 4 additions & 0 deletions apps/web/src/runtime/is-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ declare global {
commands: {
on(callback: (command: VellumCommand) => void): () => void;
};
dock: {
setBadge(count: number): Promise<void>;
setSignedIn(signedIn: boolean): Promise<void>;
};
};
}
}
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/utils/conversation-predicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,18 @@ export function canMarkRead(conversation: Conversation): boolean {
conversation.conversationId != null
);
}

/**
* Whether this conversation should be reflected in user-visible unread
* counters (sidebar attention, Dock badge, etc.). Excludes archived
* threads and automated background / scheduled threads — those have
* their own surfaces and don't represent attention the user is
* expected to clear.
*/
export function contributesToUnreadCount(conversation: Conversation): boolean {
return (
conversation.hasUnseenLatestAssistantMessage === true &&
!isBackgroundConversation(conversation) &&
conversation.archivedAt == null
);
}