diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 6d8fb43a..3067a1a0 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -4080,11 +4080,21 @@ const resolveTraySurface = () => { const trayIconAssets = () => { const dir = path.join(resourceRoot(), 'icons', 'tray'); + const statusDir = path.join(dir, 'status'); return { idleIconPath: path.join(dir, 'trayTemplate-idle.png'), unseenIconPath: path.join(dir, 'trayTemplate-unseen.png'), breathIconPaths: Array.from({ length: TRAY_BREATH_FRAME_COUNT }, (_, i) => path.join(dir, `trayTemplate-breath-${String(i).padStart(2, '0')}.png`)), + // Per-session status icons shown in the menu rows (left, vertically centred + // across the title + sublabel). 'blank' reserves the gutter for idle rows. + statusIconPaths: { + busy: path.join(statusDir, 'busy.png'), + retry: path.join(statusDir, 'retry.png'), + error: path.join(statusDir, 'error.png'), + unseen: path.join(statusDir, 'unseen.png'), + blank: path.join(statusDir, 'blank.png'), + }, }; }; diff --git a/packages/electron/resources/icons/tray/status/blank.png b/packages/electron/resources/icons/tray/status/blank.png new file mode 100644 index 00000000..d3e18382 Binary files /dev/null and b/packages/electron/resources/icons/tray/status/blank.png differ diff --git a/packages/electron/resources/icons/tray/status/blank@2x.png b/packages/electron/resources/icons/tray/status/blank@2x.png new file mode 100644 index 00000000..8b413cb6 Binary files /dev/null and b/packages/electron/resources/icons/tray/status/blank@2x.png differ diff --git a/packages/electron/resources/icons/tray/status/busy.png b/packages/electron/resources/icons/tray/status/busy.png new file mode 100644 index 00000000..3af05cbb Binary files /dev/null and b/packages/electron/resources/icons/tray/status/busy.png differ diff --git a/packages/electron/resources/icons/tray/status/busy@2x.png b/packages/electron/resources/icons/tray/status/busy@2x.png new file mode 100644 index 00000000..f0f1f726 Binary files /dev/null and b/packages/electron/resources/icons/tray/status/busy@2x.png differ diff --git a/packages/electron/resources/icons/tray/status/error.png b/packages/electron/resources/icons/tray/status/error.png new file mode 100644 index 00000000..1aabb50d Binary files /dev/null and b/packages/electron/resources/icons/tray/status/error.png differ diff --git a/packages/electron/resources/icons/tray/status/error@2x.png b/packages/electron/resources/icons/tray/status/error@2x.png new file mode 100644 index 00000000..c195337d Binary files /dev/null and b/packages/electron/resources/icons/tray/status/error@2x.png differ diff --git a/packages/electron/resources/icons/tray/status/retry.png b/packages/electron/resources/icons/tray/status/retry.png new file mode 100644 index 00000000..96cacbe0 Binary files /dev/null and b/packages/electron/resources/icons/tray/status/retry.png differ diff --git a/packages/electron/resources/icons/tray/status/retry@2x.png b/packages/electron/resources/icons/tray/status/retry@2x.png new file mode 100644 index 00000000..59d90be2 Binary files /dev/null and b/packages/electron/resources/icons/tray/status/retry@2x.png differ diff --git a/packages/electron/resources/icons/tray/status/unseen.png b/packages/electron/resources/icons/tray/status/unseen.png new file mode 100644 index 00000000..cb279ab9 Binary files /dev/null and b/packages/electron/resources/icons/tray/status/unseen.png differ diff --git a/packages/electron/resources/icons/tray/status/unseen@2x.png b/packages/electron/resources/icons/tray/status/unseen@2x.png new file mode 100644 index 00000000..d22e7bf1 Binary files /dev/null and b/packages/electron/resources/icons/tray/status/unseen@2x.png differ diff --git a/packages/electron/tray.mjs b/packages/electron/tray.mjs index b96dbcdd..5a8bb339 100644 --- a/packages/electron/tray.mjs +++ b/packages/electron/tray.mjs @@ -26,22 +26,20 @@ const truncate = (value, max) => { return `${text.slice(0, Math.max(0, max - 1))}…`; }; -// Glyph only for sessions that have an actual state; idle sessions get blank -// indentation so the row reads cleanly without an empty circle. -const statusGlyph = (session) => { - if (session.status === 'busy') return '●'; - if (session.status === 'retry') return '⟳'; - if (session.hasError) return '▲'; - if (session.unseen > 0) return '✓'; - return ''; +// Which status icon key a session maps to. 'blank' (a transparent image) +// reserves the same left gutter for idle rows so every row aligns. +const statusIconKey = (session) => { + if (session.status === 'busy') return 'busy'; + if (session.status === 'retry') return 'retry'; + if (session.hasError) return 'error'; + if (session.unseen > 0) return 'unseen'; + return 'blank'; }; const sessionLabel = (session) => { - const glyph = statusGlyph(session); - const parts = [glyph || ' ', truncate(session.title || 'Untitled session', 38)]; - if (session.branch) parts.push(`⎇ ${truncate(session.branch, 18)}`); - if (session.unseen > 0) parts.push(`(${session.unseen})`); - return parts.join(' '); + // The status is a native left icon (the ✓ already signals unread), so the + // label is just the session title. + return truncate(session.title || 'Untitled session', 40); }; const approvalLabel = (approval) => { @@ -96,7 +94,7 @@ const toTemplateImage = (p) => { // idleIconPath: plain outline (calm state). unseenIconPath: statically filled // (a finished session left unread). breathIconPaths: eased outline→fill frames // the busy state ping-pongs through. -export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconPaths, onAction }) => { +export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconPaths, statusIconPaths, onAction }) => { let tray = null; let lastTitle = null; @@ -104,6 +102,11 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP const idleFrame = toTemplateImage(idleIconPath); const unseenFrame = toTemplateImage(unseenIconPath); const breathFrames = breathIconPaths.map(toTemplateImage); + // Per-row status icons (template images, tinted + vertically centred by macOS). + const statusIcons = {}; + for (const [key, p] of Object.entries(statusIconPaths || {})) { + statusIcons[key] = toTemplateImage(p); + } let iconState = null; let animTimer = null; @@ -201,6 +204,11 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP const sessionItem = (session) => ({ label: sessionLabel(session), + // Status icon on the left, centred across both lines; idle uses the blank + // placeholder so every row keeps the same gutter. + icon: statusIcons[statusIconKey(session)] || statusIcons.blank, + // Secondary smaller line (macOS): project · branch. + ...(session.subtitle ? { sublabel: truncate(session.subtitle, 48) } : {}), click: () => onAction({ type: 'focus-session', sessionId: session.id }), }); diff --git a/packages/ui/src/hooks/useTraySync.ts b/packages/ui/src/hooks/useTraySync.ts index 72a62bec..076fa1b4 100644 --- a/packages/ui/src/hooks/useTraySync.ts +++ b/packages/ui/src/hooks/useTraySync.ts @@ -14,6 +14,12 @@ import { } from '@/stores/useGlobalSessionsStore'; import { useQuotaStore } from '@/stores/useQuotaStore'; import { QUOTA_PROVIDERS, formatWindowLabel, formatQuotaValueLabel } from '@/lib/quota'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useGitStore } from '@/stores/useGitStore'; +import { resolveProjectForSessionDirectory, normalizeProjectPath } from '@/lib/projectResolution'; +import type { ProjectEntry } from '@/lib/api/types'; +import type { WorktreeMetadata } from '@/types/worktree'; import { toast } from '@/components/ui'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; @@ -45,6 +51,8 @@ type TraySession = { unseen: number; hasError: boolean; directory: string; + // Secondary line for the menu row: "project · branch" (rendered as sublabel). + subtitle: string; }; type TrayApproval = { @@ -104,6 +112,48 @@ const questionLabel = (request: QuestionRequest): string => { const updatedAt = (session: Session): number => session.time?.updated ?? session.time?.created ?? 0; +const basenameOf = (p: string): string => { + const norm = p.replace(/\\/g, '/').replace(/\/+$/, ''); + const idx = norm.lastIndexOf('/'); + return idx >= 0 ? norm.slice(idx + 1) : norm; +}; + +// Resolve the "project · branch" metadata line for a session from its directory, +// covering both project-root sessions and worktree sessions (which map back to +// their parent project). Branch prefers the live VCS value; falls back to the +// worktree's recorded branch when the directory isn't currently synced. +const resolveSessionSubtitle = ( + directory: string, + session: Session, + projects: ProjectEntry[], + worktreesByProject: Map, + branchByDirectory: Map, +): string => { + if (!directory) return ''; + const project = resolveProjectForSessionDirectory(projects, worktreesByProject, directory); + const globalProjectName = (session as { project?: { name?: string } | null }).project?.name; + const projectName = project?.label?.trim() || globalProjectName?.trim() || basenameOf(directory); + const normDir = normalizeProjectPath(directory); + + // Branch resolution, most-authoritative first: live VCS from the sync store, + // then the git store's cached status (covers project-root sessions whose + // directory isn't actively synced), then recorded worktree metadata. + let branch = (normDir && branchByDirectory.get(normDir)) || ''; + if (!branch) { + for (const [dir, gitState] of useGitStore.getState().directories) { + if (normalizeProjectPath(dir) === normDir) { branch = gitState.status?.current ?? ''; break; } + } + } + if (!branch) { + for (const worktrees of worktreesByProject.values()) { + const match = worktrees.find((wt) => normalizeProjectPath(wt.path) === normDir); + if (match?.branch) { branch = match.branch; break; } + } + } + + return branch ? `${projectName} · ${branch}` : projectName; +}; + // Build the usage groups exactly like the header/mobile: only providers the // user enabled for the dropdown AND that report as configured. Window rows only // (the headline limits) — model breakdowns stay in the full UI. @@ -179,7 +229,9 @@ const collectLiveData = (): LiveData => { for (const [directory, store] of stores.children.entries()) { const state = store.getState(); - if (state.vcs?.branch) branchByDirectory.set(directory, state.vcs.branch); + // Normalize the key so it matches the session directory regardless of + // trailing slashes / separators. + if (state.vcs?.branch) branchByDirectory.set(normalizeProjectPath(directory) ?? directory, state.vcs.branch); for (const session of state.session) { if (!session?.id) continue; @@ -249,6 +301,9 @@ const buildSnapshot = (instanceName: string): TraySnapshot => { return 'idle'; }; + const projects = useProjectsStore.getState().projects; + const worktreesByProject = useSessionUIStore.getState().availableWorktreesByProject; + const sessions: TraySession[] = allSessions .filter((s) => s?.id && !s.parentID) // root rows; sub-session work rolls up .slice() @@ -265,6 +320,7 @@ const buildSnapshot = (instanceName: string): TraySnapshot => { unseen: family.reduce((sum, id) => sum + (notif.unseenCount[id] ?? 0), 0), hasError: family.some((id) => notif.unseenHasError[id] ?? false), directory, + subtitle: resolveSessionSubtitle(directory, session, projects, worktreesByProject, live.branchByDirectory), }; }); @@ -353,6 +409,11 @@ export const useTraySync = (): void => { // The global store drives the session list. It updates instantly via SSE // for the active directory; subscribe so those land in the tray at once. const unsubscribeGlobal = useGlobalSessionsStore.subscribe(() => scheduleFlush()); + // Project labels and discovered worktrees feed the "project · branch" + // subtitle; refresh the tray when they change (deduped, so cheap). + const unsubscribeProjects = useProjectsStore.subscribe(() => scheduleFlush()); + const unsubscribeWorktrees = useSessionUIStore.subscribe(() => scheduleFlush()); + const unsubscribeGit = useGitStore.subscribe(() => scheduleFlush()); // Make the tray self-sufficient: load the full cross-project list now // (independent of the sidebar) and refresh it periodically so sessions from @@ -391,6 +452,9 @@ export const useTraySync = (): void => { window.clearInterval(usageRefreshTick); unsubscribeNotif(); unsubscribeGlobal(); + unsubscribeProjects(); + unsubscribeWorktrees(); + unsubscribeGit(); unsubscribeQuota(); unsubscribeRegistry?.(); for (const unsub of storeUnsubs.values()) unsub();