feat(desktop): per-session metadata and status icons in the tray menu

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.
This commit is contained in:
Bohdan Triapitsyn
2026-06-10 01:58:11 +03:00
parent ae46060e1d
commit b27e8efb5f
13 changed files with 97 additions and 15 deletions
+10
View File
@@ -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'),
},
};
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

+22 -14
View File
@@ -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 }),
});