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.
@@ -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'),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 93 B |
|
After Width: | Height: | Size: 101 B |
|
After Width: | Height: | Size: 265 B |
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 320 B |
|
After Width: | Height: | Size: 661 B |
|
After Width: | Height: | Size: 325 B |
|
After Width: | Height: | Size: 608 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 242 B |
@@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, WorktreeMetadata[]>,
|
||||
branchByDirectory: Map<string, string>,
|
||||
): 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();
|
||||
|
||||