Claude Desktop sidebar that shows only what you work on: one section per project, busiest on top, folders idle a day or more hidden

Too many Claude Code sessions and projects in the Claude Desktop sidebar? Focus Sidebar is a Claude Code mod (a hooks plugin) that organizes the Code sidebar by project, keeps the project you are working on right now at the top, and hides folders you have not touched for a working day. It runs on its own, with no clicks.
<img alt="Before: every folder ever opened, in one long list. After: only current projects, busiest on top." src="docs/images/hero.png">
Fix checkout flow · Oct 23, 10:13, in your own time zone, and is updated when the session comes back to life./show-folder lists the hidden folders, /show-folder <name> restores one.In a terminal Claude Code session:
/plugin install focus-sidebar --marketplace zigansh1n/claude-focus-sidebar
Answer y to add the marketplace and pick the user scope. Or from a shell:
claude plugin install focus-sidebar --marketplace zigansh1n/claude-focus-sidebar
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with zigansh1n/claude-focus-sidebar, then Add plugin.
The mod starts working in the next Claude Desktop session you open or resume. It needs a Claude Code build with hooks modules (mods); if nothing happens, see claude --debug for a hooks module focus-sidebar ... loaded line.
How do I organize Claude Code sessions by project in Claude Desktop? Install Focus Sidebar. Every session is filed into a sidebar section named after its project folder (the git repo root), and new sessions are filed the same way a few seconds after they start.
How do I hide old Claude Code sessions in the sidebar? Folders you skip for a full working day are hidden automatically: their sessions move to the app's Archived list. Nothing is deleted, and a folder comes back as soon as you work in it again or run /show-folder <name>.
Will a weekend hide all my projects? No. Only days you actually worked count, so after days off the sidebar looks the way you left it.
Does it work with the Claude Code terminal (CLI)? It arranges Claude Desktop's sidebar, so you need Claude Desktop. Terminal sessions on the same Mac load the mod too and can trigger a re-sort while Claude Desktop is running.
How do I uninstall it? claude plugin uninstall focus-sidebar. Hidden sessions stay in the Archived list, where you can restore them.
The sidebar has no call to reorder sections, but a renamed section keeps its place and a new one is added after the other custom sections. So the mod owns a run of "slot" sections and renames them by rank instead of moving them. It syncs 5 s after a session starts, 2 s after each turn and every 5 minutes; a short lease keeps several open sessions from syncing at once. Log: ~/.claude/focus-sidebar.log.
claude plugin test .
MIT
The pictures are rendered from docs/render/sidebar.html.
hooks/register.ts 404 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The sidebar has no "reorder sections" call, but a renamed section keeps its
4// place and a new one lands after the other custom sections. So the mod owns a
5// fixed run of "slot" sections and renames them by rank instead of moving them.
6
7const SIDEBAR = 'ccd_sidebar'
8const SESSIONS = 'ccd_session_mgmt'
9const DAY = 86_400_000
10// A folder left out of this many of your working days is hidden: its sessions are archived
11// (/show-folder brings them back). Days you did not work at all do not count, so a weekend hides nothing.
12const HIDE_AFTER_DAYS = 1
13// Working days remembered, enough to measure any folder still visible.
14const WORK_DAYS_KEPT = 60
15const HEAT_DAYS = 14
16const HALF_LIFE_DAYS = 3
17const FIRST_RUN_MS = 5_000
18const AFTER_TURN_MS = 2_000
19const PERIOD_MS = 5 * 60_000
20// Every open session runs this mod; the lease keeps them from syncing at once.
21const LEASE_MS = 45_000
22const MOVE_BATCH = 100
23// Archived sessions are left out of the listing: with them it outgrows what a tool result may carry.
24const LIST_LIMIT = 300
25// A folder worked in this recently gets 🔥 in its section name.
26const HOT_MS = 60 * 60_000
27// Titles stamped per sync, so a first run over many sessions stays short.
28const STAMP_BATCH = 20
29// Below this many sessions a section's own subheadings cost more room than they save.
30const GROUP_MIN = 3
31
32export const ARCHIVE = '🧊 Archive'
33export const SCRATCH = '💬 No folder'
34export const HOME = '🏠 Home'
35export const COMMAND = 'show-folder'
36export const HOT = '🔥'
37const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
38
39export type Session = {
40 sessionId: string
41 title?: string
42 cwd: string
43 lastActivityAt: string
44 isArchived?: boolean
45 pinned?: boolean
46 isRunning?: boolean
47 startedBy?: string
48 // Absent while the app has not reported groups yet: unknown, not ungrouped.
49 group?: { id: string; name: string } | null
50}
51
52type Row = Session & { project: string }
53// Inside a section the app itself groups rows: by state (needs input, running, done) or by day.
54export type Slot = { name: string; sessionIds: string[]; groupBy: 'state' | 'date' | 'none' }
55export type Plan = { slots: Slot[]; hide: string[] }
56/** A hidden folder, by the label /show-folder lists it under. */
57export type Hidden = Record<string, { folder: string; ids: string[] }>
58
59type Section = { id: string; kind: string; name: string; group_by?: string }
60type Result = { isError: boolean; content: { type: string; text?: string }[] }
61
62const textOf = (r: Result) => r.content.map(b => b.text ?? '').join('')
63
64export const homeOf = (path: string): string | null => path.match(/^\/(?:Users|home)\/[^/]+/)?.[0] ?? null
65
66/** The project a session in `cwd` belongs to: its git repo root's name, else the folder's. */
67export const projectOf = async (cwd: string, exists: (p: string) => Promise<boolean>): Promise<string> => {
68 if (!cwd || cwd.includes('/scratch-workspaces/') || /^\/(private\/)?tmp(\/|$)/.test(cwd)) return SCRATCH
69
70 let dir = cwd.replace(/\/\.claude\/worktrees\/[^/]+.*$/, '').replace(/\/+$/, '')
71 const home = homeOf(dir)
72 if (dir === home) return HOME
73
74 for (let q = dir; q.length > (home ?? '').length && q.includes('/'); q = q.slice(0, q.lastIndexOf('/'))) {
75 if (await exists(`${q}/.git`)) {
76 dir = q
77 break
78 }
79 }
80 return dir.slice(dir.lastIndexOf('/') + 1) || dir
81}
82
83/** What is hidden as one: a project, or a single no-folder session's own scratch folder. */
84export const folderOf = (s: Row) => (s.project === SCRATCH ? `${SCRATCH}:${s.cwd}` : s.project)
85
86const labelOf = (s: Row) => (s.project === SCRATCH ? `💬 ${s.title ?? s.sessionId}` : s.project)
87
88/** A session the mod may file: in the sidebar, not pinned or nested, and not filed by hand elsewhere. */
89export const isMovable = (s: Session, owned: ReadonlySet<string>) =>
90 !s.isArchived && !s.pinned && !s.startedBy && s.group !== undefined && (s.group === null || owned.has(s.group.id))
91
92/** The local calendar day of a moment, `2026-10-23`. */
93export const dayOf = (ms: number): string => {
94 const d = new Date(ms)
95 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
96}
97
98/**
99 * The slots top to bottom and the sessions to hide. Folders worked in during the
100 * last hour come first, newest first, marked 🔥; the rest follow by heat (recent
101 * sessions weigh more). A folder is hidden once HIDE_AFTER_DAYS of your finished
102 * working days (`workDays`, today excluded) went by without it. Folders in
103 * `shown` were just brought back and stay. Idle sessions the app would not
104 * archive (`kept`) go to the archive slot instead.
105 */
106export const plan = (
107 sessions: Row[],
108 owned: ReadonlySet<string>,
109 now: number,
110 kept: ReadonlySet<string> = new Set(),
111 shown: ReadonlySet<string> = new Set(),
112 workDays: readonly string[] = sessions.map(s => dayOf(Date.parse(s.lastActivityAt) || 0)),
113): Plan => {
114 const lastByFolder = new Map<string, number>()
115 for (const s of sessions) {
116 const at = Date.parse(s.lastActivityAt) || 0
117 lastByFolder.set(folderOf(s), Math.max(lastByFolder.get(folderOf(s)) ?? 0, at))
118 }
119 const today = dayOf(now)
120 const days = [...new Set(workDays)]
121 const missedDays = (last: number) => days.filter(d => d > dayOf(last) && d < today).length
122 const isIdle = (s: Row) =>
123 !s.isRunning && !shown.has(folderOf(s)) && missedDays(lastByFolder.get(folderOf(s)) ?? 0) >= HIDE_AFTER_DAYS
124
125 const projects = new Map<string, { heat: number; last: number; ids: string[] }>()
126 const hide: string[] = []
127 const archived: string[] = []
128 for (const s of sessions) {
129 const at = Date.parse(s.lastActivityAt) || 0
130 const p = projects.get(s.project) ?? { heat: 0, last: 0, ids: [] }
131 const ageDays = (now - at) / DAY
132 if (ageDays <= HEAT_DAYS) p.heat += 0.5 ** (Math.max(ageDays, 0) / HALF_LIFE_DAYS)
133 p.last = Math.max(p.last, at)
134 if (isMovable(s, owned)) {
135 if (!isIdle(s)) p.ids.push(s.sessionId)
136 else if (kept.has(s.sessionId)) archived.push(s.sessionId)
137 else hide.push(s.sessionId)
138 }
139 projects.set(s.project, p)
140 }
141
142 const isHot = (name: string, last: number) => name !== SCRATCH && now - last < HOT_MS
143 const slots: Slot[] = [...projects]
144 .filter(([, p]) => p.ids.length > 0)
145 .sort(([an, a], [bn, b]) => {
146 const ah = isHot(an, a.last)
147 const bh = isHot(bn, b.last)
148 if (ah !== bh) return ah ? -1 : 1
149 return ah ? b.last - a.last : b.heat - a.heat || b.last - a.last
150 })
151 .map(([name, p]) => ({
152 name: isHot(name, p.last) ? `${HOT} ${name}` : name,
153 sessionIds: p.ids,
154 groupBy: p.ids.length < GROUP_MIN ? 'none' : name === SCRATCH ? 'date' : 'state',
155 }))
156 if (archived.length > 0) {
157 slots.push({ name: ARCHIVE, sessionIds: archived, groupBy: archived.length < GROUP_MIN ? 'none' : 'date' })
158 }
159 return { slots, hide }
160}
161
162/** `Oct 23, 10:30` in the machine's own time zone. */
163export const stampOf = (iso: string): string => {
164 const d = new Date(iso)
165 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
166 return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${hm}`
167}
168
169const STAMP = / · [A-Z][a-z]{2} \d{1,2}, \d{2}:\d{2}$/
170
171/** The title with its trailing stamp replaced by the session's last activity. */
172export const restamp = (title: string, lastActivityAt: string) => `${title.replace(STAMP, '')} · ${stampOf(lastActivityAt)}`
173
174let logFile: string | null = null
175
176/** Brings the sidebar in line with the plan; resolves what it did, for the log. */
177export async function sync($: EngineInterface): Promise<string> {
178 const log = async (line: string) => {
179 if (!logFile) return
180 try {
181 // Background processes without the sidebar fail the same way every tick: keep one line per change.
182 if ((await $.store.get('lastLog')) === line) return
183 await $.store.set('lastLog', line)
184 const prev = (await $.fs.exists(logFile)) ? await $.fs.read(logFile) : ''
185 await $.fs.write(logFile, (prev + `${new Date().toISOString()} ${line}\n`).slice(-20000))
186 } catch {}
187 }
188 const call = async (server: string, tool: string, args: Record<string, unknown>) => {
189 const r = (await $.mcp.call(server, tool, args)) as Result
190 if (r.isError) throw new Error(`${tool}: ${textOf(r)}`)
191 return textOf(r)
192 }
193 const json = async (server: string, tool: string, args: Record<string, unknown>) => {
194 const text = await call(server, tool, args)
195 try {
196 return JSON.parse(text) as unknown
197 } catch {
198 throw new Error(`${tool}: ${text.slice(0, 160)}`)
199 }
200 }
201
202 try {
203 const now = await $.clock.now()
204 if (Number((await $.store.get('leaseUntil')) ?? 0) > now) return 'leased'
205 await $.store.set('leaseUntil', now + LEASE_MS)
206
207 const others = (await json(SESSIONS, 'list_sessions', { limit: LIST_LIMIT })) as Session[]
208 const self = (await json(SESSIONS, 'get_session', { session_id: 'self' })) as Session
209 const all = [self, ...others.filter(s => s.sessionId !== self.sessionId)]
210
211 const cache = new Map<string, Promise<string>>()
212 const rows: Row[] = await Promise.all(
213 all.map(async s => {
214 if (!cache.has(s.cwd)) cache.set(s.cwd, projectOf(s.cwd, p => $.fs.exists(p)))
215 return { ...s, project: await cache.get(s.cwd)! }
216 }),
217 )
218
219 const sections = (await json(SIDEBAR, 'list_sections', {})) as Section[]
220 const byId = new Map(sections.filter(s => s.kind === 'manual').map(s => [s.id, s]))
221 const owned = (((await $.store.get('slots')) as string[] | undefined) ?? []).filter(id => byId.has(id))
222 // Routine runs look like any other session in the list, yet the sidebar refuses to file them.
223 const refused = new Set(((await $.store.get('refused')) as string[] | undefined) ?? [])
224 const kept = new Set(((await $.store.get('kept')) as string[] | undefined) ?? [])
225 const shownUntil = ((await $.store.get('shownUntil')) as Record<string, number> | undefined) ?? {}
226 const shown = new Set(Object.keys(shownUntil).filter(f => shownUntil[f] > now))
227 const seenDays = rows.map(s => dayOf(Date.parse(s.lastActivityAt) || 0))
228 const workDays = [...new Set([...(((await $.store.get('workDays')) as string[] | undefined) ?? []), ...seenDays])]
229 .sort()
230 .slice(-WORK_DAYS_KEPT)
231 await $.store.set('workDays', workDays)
232 const { slots, hide } = plan(
233 rows.filter(s => !refused.has(s.sessionId)),
234 new Set(owned),
235 now,
236 kept,
237 shown,
238 workDays,
239 )
240
241 const hiddenFolders = ((await $.store.get('hidden')) as Hidden | undefined) ?? {}
242 const byIdRow = new Map(rows.map(s => [s.sessionId, s]))
243 let hidden = 0
244 for (const id of hide.filter(id => id !== self.sessionId)) {
245 try {
246 // A routine's runs belong to the routine: leave its history as the app keeps it.
247 const detail = (await json(SESSIONS, 'get_session', { session_id: id })) as { scheduledTaskId?: string }
248 if (detail.scheduledTaskId) {
249 refused.add(id)
250 await $.store.set('refused', [...refused])
251 continue
252 }
253 await call(SESSIONS, 'archive_session', { session_id: id, reason: `folder idle ${HIDE_AFTER_DAYS}+ day` })
254 const row = byIdRow.get(id)!
255 const entry = hiddenFolders[labelOf(row)] ?? { folder: folderOf(row), ids: [] }
256 if (!entry.ids.includes(id)) entry.ids.push(id)
257 hiddenFolders[labelOf(row)] = entry
258 hidden++
259 } catch {
260 kept.add(id)
261 }
262 }
263 // Working in a hidden folder again brings its other sessions back with it.
264 const liveFolders = new Set(slots.flatMap(slot => slot.sessionIds).map(id => folderOf(byIdRow.get(id)!)))
265 let restored = 0
266 for (const [label, entry] of Object.entries(hiddenFolders)) {
267 if (!liveFolders.has(entry.folder)) continue
268 for (const id of entry.ids) {
269 const r = (await $.mcp.call(SESSIONS, 'unarchive_session', { session_id: id })) as Result
270 if (!r.isError) restored++
271 }
272 delete hiddenFolders[label]
273 await $.store.set('leaseUntil', 0)
274 }
275 if (hidden > 0 || restored > 0) await $.store.set('hidden', hiddenFolders)
276 if (hidden < hide.length) await $.store.set('kept', [...kept])
277
278 while (owned.length < slots.length) {
279 const made = (await json(SIDEBAR, 'create_section', { name: slots[owned.length].name })) as Section
280 owned.push(made.id)
281 byId.set(made.id, made)
282 }
283 await $.store.set('slots', owned)
284
285 let renamed = 0
286 let moved = 0
287 for (const [i, slot] of slots.entries()) {
288 if (byId.get(owned[i])?.name !== slot.name) {
289 await call(SIDEBAR, 'rename_section', { section_id: owned[i], name: slot.name })
290 renamed++
291 }
292 const toMove = slot.sessionIds.filter(id => byIdRow.get(id)?.group?.id !== owned[i])
293 for (let k = 0; k < toMove.length; k += MOVE_BATCH) {
294 const batch = toMove.slice(k, k + MOVE_BATCH)
295 try {
296 await call(SIDEBAR, 'move_sessions', { session_ids: batch, section_id: owned[i] })
297 moved += batch.length
298 } catch {
299 // One refused id fails the whole batch: retry one by one and remember the refusals.
300 for (const id of batch) {
301 try {
302 await call(SIDEBAR, 'move_sessions', { session_ids: [id], section_id: owned[i] })
303 moved++
304 } catch {
305 refused.add(id)
306 }
307 }
308 await $.store.set('refused', [...refused])
309 }
310 }
311 }
312
313 for (const [i, slot] of slots.entries()) {
314 if (byId.get(owned[i])?.group_by !== slot.groupBy) {
315 await call(SIDEBAR, 'set_view', { section_id: owned[i], group_by: slot.groupBy, sort_by: 'recency' })
316 }
317 }
318
319 // Renaming does not count as activity, so the stamp settles once the session goes quiet.
320 // The app regenerates a title after the first exchange; a lost stamp is simply added again.
321 let stamped = 0
322 const stale = slots
323 .flatMap(slot => slot.sessionIds)
324 .map(id => byIdRow.get(id)!)
325 .filter(s => s.title && restamp(s.title, s.lastActivityAt) !== s.title)
326 .slice(0, STAMP_BATCH)
327 for (const s of stale) {
328 try {
329 await call(SESSIONS, 'set_session_title', { session_id: s.sessionId, title: restamp(s.title!, s.lastActivityAt) })
330 stamped++
331 } catch {}
332 }
333
334 const extra = owned.splice(slots.length)
335 for (const id of extra) await call(SIDEBAR, 'delete_section', { section_id: id })
336 await $.store.set('slots', owned)
337
338 const line = `order: ${slots.map(s => s.name).join(' | ')}; renamed ${renamed}, moved ${moved}, removed ${extra.length}, hidden ${hidden}, restored ${restored}, stamped ${stamped}`
339 if (renamed || moved || extra.length || hidden || restored || stamped) await log(line)
340 return line
341 } catch (err) {
342 const line = `skipped: ${String(err).slice(0, 300)}`
343 await log(line)
344 return line
345 }
346}
347
348/** /show-folder: lists the hidden folders, or brings back the ones matching `query` for a day. */
349export async function showFolder($: EngineInterface, query: string): Promise<string> {
350 const hiddenFolders = ((await $.store.get('hidden')) as Hidden | undefined) ?? {}
351 const labels = Object.keys(hiddenFolders)
352 const q = query.trim().toLowerCase()
353 if (!q) {
354 return labels.length === 0
355 ? 'No hidden folders.'
356 : `Hidden folders:\n${labels.map(l => `- ${l}`).join('\n')}\n\nBring one back: /${COMMAND} <name>`
357 }
358
359 const matched = labels.filter(l => l.toLowerCase().includes(q))
360 if (matched.length === 0) return `No hidden folder matches "${query.trim()}". List them: /${COMMAND}`
361
362 const now = await $.clock.now()
363 const shownUntil = ((await $.store.get('shownUntil')) as Record<string, number> | undefined) ?? {}
364 let back = 0
365 for (const label of matched) {
366 const { folder, ids } = hiddenFolders[label]
367 for (const id of ids) {
368 const r = (await $.mcp.call(SESSIONS, 'unarchive_session', { session_id: id })) as Result
369 if (!r.isError) back++
370 }
371 shownUntil[folder] = now + HIDE_AFTER_DAYS * DAY
372 delete hiddenFolders[label]
373 }
374 await $.store.set('shownUntil', shownUntil)
375 await $.store.set('hidden', hiddenFolders)
376 await $.store.set('leaseUntil', 0)
377 await sync($)
378 return `Back in the sidebar: ${matched.join(', ')} (${back} sessions). It hides again after a working day without it.`
379}
380
381export const register: Register = on => {
382 on('session.start', async ($, e, next) => {
383 const started = await next(e)
384 const home = homeOf(e.cwd)
385 logFile = home ? `${home}/.claude/focus-sidebar.log` : null
386 await $.command.register({
387 name: COMMAND,
388 description: 'Bring a hidden folder back to the sidebar (no name: list hidden folders)',
389 argumentHint: '[folder]',
390 })
391 $.clock.after(FIRST_RUN_MS, () => void sync($))
392 $.clock.every(PERIOD_MS, () => void sync($))
393 return started
394 })
395
396 on('turn.complete', async ($, e, next) => {
397 const done = await next(e)
398 $.clock.after(AFTER_TURN_MS, () => void sync($))
399 return done
400 })
401
402 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await showFolder($, e.args) }))
403}
404