From b27e8efb5f49bd84795e1fab3f0d1deedc4051c0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 10 Jun 2026 01:58:11 +0300 Subject: [PATCH] feat(desktop): per-session metadata and status icons in the tray menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrich each session row in the macOS tray menu and refine its layout. - Add a "project · branch" sublabel to every session row, resolved from the session directory: project-root sessions map to their project + live/cached git branch; worktree sessions map back to their parent project and use the worktree's branch. Branch resolution falls back live VCS → git store → worktree metadata, with normalized directory keys. - Replace the inline text status glyph with a native left-aligned status icon (vertically centred across the title + sublabel). Idle rows use a transparent placeholder so every row shares the same gutter and both text lines align. - Status icons use the app icon set: pulse (busy), check (unread), error-warning (error), loop-right (retry); all rendered as tinted template images. - Drop the unread count "(n)" from the row label — the check icon already signals it and the number wasn't self-explanatory. - Show the first 8 sessions inline; the rest stay in the overflow submenu. - Subscribe the tray to the projects, worktree and git stores so subtitles stay current. --- packages/electron/main.mjs | 10 +++ .../resources/icons/tray/status/blank.png | Bin 0 -> 93 bytes .../resources/icons/tray/status/blank@2x.png | Bin 0 -> 101 bytes .../resources/icons/tray/status/busy.png | Bin 0 -> 265 bytes .../resources/icons/tray/status/busy@2x.png | Bin 0 -> 429 bytes .../resources/icons/tray/status/error.png | Bin 0 -> 320 bytes .../resources/icons/tray/status/error@2x.png | Bin 0 -> 661 bytes .../resources/icons/tray/status/retry.png | Bin 0 -> 325 bytes .../resources/icons/tray/status/retry@2x.png | Bin 0 -> 608 bytes .../resources/icons/tray/status/unseen.png | Bin 0 -> 182 bytes .../resources/icons/tray/status/unseen@2x.png | Bin 0 -> 242 bytes packages/electron/tray.mjs | 36 ++++++---- packages/ui/src/hooks/useTraySync.ts | 66 +++++++++++++++++- 13 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 packages/electron/resources/icons/tray/status/blank.png create mode 100644 packages/electron/resources/icons/tray/status/blank@2x.png create mode 100644 packages/electron/resources/icons/tray/status/busy.png create mode 100644 packages/electron/resources/icons/tray/status/busy@2x.png create mode 100644 packages/electron/resources/icons/tray/status/error.png create mode 100644 packages/electron/resources/icons/tray/status/error@2x.png create mode 100644 packages/electron/resources/icons/tray/status/retry.png create mode 100644 packages/electron/resources/icons/tray/status/retry@2x.png create mode 100644 packages/electron/resources/icons/tray/status/unseen.png create mode 100644 packages/electron/resources/icons/tray/status/unseen@2x.png 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 0000000000000000000000000000000000000000..d3e18382481341ce759744d3be9b05dfb5f3ca85 GIT binary patch literal 93 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkE)4%caKYZ?lYt^ao-U3d l7QM*{60D08M0%Jw7#LUt7#Qn)EBb)444$rjF6*2UngE;p6|VpQ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8b413cb6893580d9a030743b0a8279b30ba69421 GIT binary patch literal 101 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v_b)PZ!6K qh}O3U85x1RCWZg&^E@0t%=c^p3Ji?yj0-w|90pHUKbLh*2~7ZoNf_|} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3af05cbb7604ef0ab2f44ae85225b63b1b981481 GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkE)4%caKYZ?lYt`pJY5_^ zEPA(Iw)H#gAi(;7U&F$A5r@VkYk?w$J_ij0SFWm4BAS{nYnd##oE9x?Yr1o2qLPM$ z_QcfFamw>dF73H^?)bS*QhsPkBR>6MKRjpBR0&t35Oxz{oNO*`akRW5&11p0)* M)78&qol`;+04!{7v;Y7A literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f0f1f7261fb40e7abf5164ae1780901e976ab89e GIT binary patch literal 429 zcmV;e0aE^nP)p9Qw17}ql z$fGv!0ek@)O&B-@B8grT23{-!W8cLOt>Pv+J`Ic%14CcM1K>xBh7ytFN+X#C!#>cG z@eMGLaSIPf8uTLIlNi4?@GiO1NT$ue39u#OTi_d*0{4>J1dhuXSd;BOdOt6K9pD$( z1}2gZfH&ecjDbDJ{g0TnZ&4HA$?RxPxg8J64TdBmsiAAf2(o_*1}V97<+zqxQd$cj zp8iM2u@KlLxkazQrE=|D3yP}8Y)Y@J{I!+umMxMb8&iX%hOSe2*8_{10(UYtNNVWT zs#;&wS!E-cgl=u>Cw7NbNMP!hS@PM96mH)V`RJ;BF X!$EY=D(UDs00000NkvXXu0mjfAP28i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1aabb50d4400b72165d67876966252792725824d GIT binary patch literal 320 zcmV-G0l)rSa4|Jz(wPCGmDHkte*ON{Rn0$Rrv>lh7zc5F!wu$}1otq(5+At71B$@L$kr9V z;2nd+T?a!f@Z2UciQ;YzJBqBL8zPr!aX%~$68t){9w))gD{I1DfoYSqByRDgeB@t& z9%fi=A+QW?4|W4wX&&j*7J}eHIkwIwVsHwa*TFoj4HI(HBc9T`|Y-5J2Xs5tqe#CT-)2{;>94}vdYLcI9( z21c^UMqa>hFwqP81aLr616R}pcDZO=F0ve457lEAotf^Qbv*G;I#Zdcs(*ELPgTKb z$3UNUBftbO3G{$Hpapy{DXvV+08fFBhBRw!0Z)MGGKt5525{^lcGoem42*@GhmN2I zyfQ(g=m2ZL4w8D1qnHKefeT9a0JsUPhXTpMZQuyIt6ktOQ1RGQfCb=-vO5CmMagRp zc_TpVA_1CC5Y<@YaU^xA@%;kCE|!#^HZU4v)ZpaK^DqajnlzD_FepG zuPevCGfuE~q6EH7oU(we-gfQu^9l#LdWQS&>wUL zMu6)^eoHyKPtgV58Tke}hEA%H>P8@3c*3k1uua@_Rj#WtfC%HOjPImj5* zRz^!PMmcz({Irm28-*$-2a+cK<8UzutSI|m__LIWx)a1#q?7pqNq~FUGf&BHCwz>!G13&uocZ%@`l>@s()_Iy300000NkvXXu0mjfc+M}W literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..96cacbe0a56d855c7b568802dd2aea983683dc1b GIT binary patch literal 325 zcmV-L0lNN)P)2HxFU>aEX3= zk8z3yj$(WxKVXPv=57uxyxua{#L1{&K7xB;@1h_u zz+r7YtOrkURUrR6dN}4Yg&i!_4h&=#Yna70Zt9zruNTxV{JkYL*XB|j_I{A>v5*T)TC*WlWYWrgT;nxtcXINy$FvqM)}@Pn#5tY{>HQ8L X!x=s!Wo&h600000NkvXXu0mjfnzM@W literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..59d90be21339d3eb02c38effcff841e77b4c2ee1 GIT binary patch literal 608 zcmV-m0-ybfP)F(&vz@GsCb z7AlC9jfvtyf`x^MK`TrDgET%6EKCs)H7Y(ZmAV+#;@)K^!`-`gH?JZe40rC#ob!Fm zoIB@SHaa$SY1fNA=))E);4OZvkgiM&;09i!-lEOo7W!9`xPmHbImB^W#Dmnffj$hU zt*dZNZHQx(F&>GSEGnR4>9&t`Kpf+e>=?-vPz@H|TLyUp6P*Yc(2Vvuoo#!FCmE!f z0yam6=dd;HUrWWTDoWA&@$|WXr_1m=(`Nod)?lyZ7ELdMyd#815Z+F2)mM u{Y9O2`)jrn2eAYH@KJKT{_N7<2F8EDyuQZMY7!#=0000V=Dir9I1Lzwkkzq cYJD%SORdQNTkdY2Kx-L1UHx3vIVCg!0J|wgyZ`_I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d22e7bf1cceb600576834dc13d279f41bd0ae352 GIT binary patch literal 242 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v~0io-U3d z5v^~p7;+tQ5O90w&+UDC#-)31N0i(}Z93Qm{1@D)Vr1RxpkpEtbcAIx?}>eN&;A;3 z)|u#`a><_aTLi!2{D(a<;W|#Igk|o3l418*yoa~AC+gY~JM;gZ`@U3}ME6KvD>3`Y zGCgQ<@B7&=ay~VQ<}Gb2o$XQZD(Ie?Oa3P{#j5GoTRoglavR<8KIHCE=~ya!uzp8Y n$AeEzJi8Y@ndtH4e+ARMX5W=8HH-9sPG<0Q^>bP0l+XkKwHsi9 literal 0 HcmV?d00001 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();